Skip to content

[PM-39767] feat: Persist Premium upgrade pending state and resolve it on any sync - #3000

Open
KatherineInCode wants to merge 15 commits into
mainfrom
pm-39767/reconcile-pending-upgrade
Open

[PM-39767] feat: Persist Premium upgrade pending state and resolve it on any sync#3000
KatherineInCode wants to merge 15 commits into
mainfrom
pm-39767/reconcile-pending-upgrade

Conversation

@KatherineInCode

@KatherineInCode KatherineInCode commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

🎟️ Tracking

PM-39767

📔 Objective

Replaces #2916. Persists Premium upgrade pending/failure state per account, and resolves it on any sync — not just the one that started the checkout. Fixes QA finding #3 ("Delayed Sync").

Checkout-success and the background sync watcher now share resolvePendingUpgrade(userId:syncFailed:), scoped to an explicit userId. No lock needed (unlike #2916): an account switch mid-flight can't corrupt another account's state, and double-processing the same sync is harmless.

Also fixes a gap left by #2873's CTA fix: none of the other eight upgrade entry points checked for an already-pending upgrade before starting a second one. PremiumUpgradeHelper.startInAppPremiumUpgrade() — the shared choke point all nine go through — now checks once for everyone.

Trade-offs:

  • lastAttemptFailed can briefly be wrong after a specific compound sync failure. Left alone — nothing reads it yet.
  • A dropped-webhook upgrade that never resolves has no escape hatch to the web fallback, since every entry point now defers to the pending alert. Revisit with the "Sync Unsuccessful" PR.

Celebration sheet and "Sync Unsuccessful" alert are later PRs in this stack; the latter's design is still open.

@github-actions github-actions Bot added app:password-manager Bitwarden Password Manager app context t:feature labels Aug 27, 2026
@codecov

codecov Bot commented Aug 27, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.65428% with 9 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.91%. Comparing base (2c24d3f) to head (4350810).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
...nShared/Core/Billing/Services/BillingService.swift 95.59% 7 Missing ⚠️
...wardenShared/UI/Billing/PremiumUpgradeHelper.swift 94.87% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3000      +/-   ##
==========================================
+ Coverage   79.54%   81.91%   +2.37%     
==========================================
  Files        1169     1047     -122     
  Lines       75095    68131    -6964     
==========================================
- Hits        59731    55812    -3919     
+ Misses      15364    12319    -3045     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@KatherineInCode
KatherineInCode marked this pull request as ready for review August 28, 2026 18:35
@KatherineInCode
KatherineInCode requested review from a team and matt-livefront as code owners August 28, 2026 18:35
@KatherineInCode KatherineInCode added the ai-review Request a Claude code review label Aug 28, 2026
… on any sync

Premium upgrade status was only tracked by an ephemeral, per-checkout-attempt
Combine subscription, and any sync failure was silently swallowed and
indistinguishable from "still waiting, no error." This meant a later,
unrelated sync (e.g. Settings > Vault > Sync Now, or a sync triggered from
the web vault) could never resolve a pending upgrade, and there was no way
to know a sync attempt had actually failed versus just not finished yet —
QA finding #3 ("Delayed Sync") on this ticket.

BillingService now persists pending/failure state per-account via
BillingStateService, and watches sync completions generically (not just the
originating checkout attempt) so any successful sync can resolve a pending
upgrade. Both the checkout-success path and the background sync watcher
share a single resolvePendingUpgrade(userId:syncFailed:) — every read and
write is scoped to an explicit userId (never "whichever account is active
now"), so an account switch mid-flight can't corrupt another account's
state, and the two callers double-processing the same sync is redundant but
never incorrect.

This is the foundation PR in a stack; the celebration sheet and "Sync
Unsuccessful" dialog (QA findings #1 and #2) are deferred to later PRs, per
the original scoping — #2's dialog design is still an open question raised
separately on this ticket.
…dy pending

premiumStatusChanged() left .pending sitting in the shared checkout-status
subject indefinitely when a sync didn't confirm Premium (the ordinary "still
processing" outcome, not a failure) — since it's a CurrentValueSubject, any
later, unrelated subscriber (opening the upgrade screen for any reason)
would immediately replay that stale value and get bounced into the pending
dialog. Now resets to nil unconditionally, matching how .confirmed already
did.

Separately, none of the nine entry points into the upgrade flow (Settings >
Plan, Send, item views, etc.) checked for an already-pending upgrade before
starting a new one — only the Vault tab's action card did (PR #2936-
equivalent). PremiumUpgradeHelper.startInAppPremiumUpgrade(), the single
choke point all of them funnel through, now checks
premiumUpgradePendingState() first and shows the pending alert directly
instead of opening a second, redundant checkout.
Code review (Standard) caught the same sticky-.pending leak fixed in
premiumStatusChanged() by an earlier commit, still present in its sibling
reconcileCheckoutSuccess() — the more commonly-exercised path now, since
PremiumUpgradeProcessor's checkout callback and the "Sync Now"/"Try again"
retries all go through it. Left unresolved, a stuck .pending could either
double-show the pending alert on the next startInAppPremiumUpgrade() call,
or (across accounts, since the subject is app-global) dismiss a different
account's freshly-opened upgrade screen with a pending alert for a checkout
it never started.

Also closes a narrower window in startInAppPremiumUpgrade(): navigatedToUpgradeScreen
is now reset before subscribeToPremiumCheckoutStatus() attaches the new live
subscription, not after the pending-state check resolves, so a status
arriving in that gap can't be judged against the previous call's leftover value.
Code review caught two issues in the watcher/reconcile split added earlier in
this branch:

resolvePendingUpgrade(userId:syncFailed:) returned a hardcoded false on its
early-exit branches (storage read failure, nothing pending), which
reconcileCheckoutSuccess() read as an authoritative Premium answer. On the
common happy path, the background watcher resolves the same sync first
(SyncService persists the last-sync time partway through fetchSync, well
before it returns), clears the pending flags, and leaves
reconcileCheckoutSuccess()'s own call hitting the early exit — reporting
.pending on a confirmed upgrade. Both early-exit branches now return the
account's actual Premium status instead.

Separately, premiumCheckoutStatusSubject was a CurrentValueSubject requiring
a manual send(nil) reset on every exit path so it wouldn't replay a stale
status to the next subscriber — a pattern that had already needed fixing
once in premiumStatusChanged() and was still missing on
reconcileCheckoutSuccess()'s account-switch guard. Switched it to a
PassthroughSubject, which never retains a value to replay, and removed the
now-unneeded resets and the compactMap(\.self) sentinel filter.
Local review found one more gap in the same class of bug already fixed
twice in this branch: the .pending dismiss branch in
subscribeToPremiumCheckoutStatus() never reset navigatedToUpgradeScreen
after consuming it, so a second .pending (e.g. a "Sync Now" retry that
also doesn't confirm) would dismiss a screen that was already closed —
both coordinators resolve .dismiss as "dismiss whatever's presented,"
so this could take out something the user opened in the meantime. Reset
the flag right before issuing the dismiss, mirroring the existing reset
at the top of startInAppPremiumUpgrade(). Added a regression test.

Also removed two tests (premiumStatusChanged_pending_resetsPublisherValue,
reconcileCheckoutSuccess_pending_resetsPublisherValue) that asserted a
late subscriber receives no replayed value — guaranteed by PassthroughSubject
itself since e7bfdca, not by anything this code does, so they could never
fail and only described the removed CurrentValueSubject/send(nil) design.
Added a real regression test in their place for the bug e7bfdca actually
fixed: reconcileCheckoutSuccess() simulating the background watcher
resolving the pending flags first, confirming its own resolvePendingUpgrade
call still reports the account's true Premium status instead of the old
hardcoded false.
@KatherineInCode
KatherineInCode force-pushed the pm-39767/reconcile-pending-upgrade branch from 2b6b326 to 14f148a Compare August 28, 2026 18:36
@github-actions

github-actions Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

🤖 Bitwarden Claude Code Review

Overall Assessment: APPROVE

Re-reviewed at 435081011. The only changes since the last pass are comment removals in BillingService+BillingStateTests.swift — no production code, assertions, or test wiring changed. I re-walked the production surface independently anyway: the new premiumUpgradePending_<userId> / premiumUpgradeLastSyncAttemptFailed_<userId> keys through AppSettingsStoreDefaultStateService's BillingStateService extension, doesAccountHavePremium(userId:) and its doesActiveAccountHavePremium() delegation, resolvePendingUpgrade(userId:syncFailed:)'s explicit-userId scoping and its isPending || lastAttemptFailed guard, start()'s account/sync watcher pair, the PremiumUpgradeProcessor switch from premiumStatusChanged() to reconcileCheckoutSuccess() (the push-notification path still uses the former), and PremiumUpgradeHelper's navigatedToUpgradeScreen / isResolvingStartRequest guards. No new findings.

Code Review Details

Still open from a previous review:

  • ♻️ : Premium-upgrade-banner flag read via billingStateService but still written via stateService
    • BitwardenShared/UI/Vault/Vault/VaultList/VaultListProcessor.swift:476

Checked and deliberately not raised:

  • logoutAccount(userId:userInitiated:) does not clear the new premiumUpgradePending_* keys, so a never-resolved upgrade survives logout/login. This is the same escape-hatch gap the PR description already defers to the "Sync Unsuccessful" PR, and it matches how the existing premiumUpgradeBannerDismissed_* / upgradedToPremiumActionCardVisible_* keys behave.
  • reconcileCheckoutSuccess() returning early from isEligibleForPremiumUpgradePath() publishes no status, leaving the upgrade screen without feedback — pre-existing, premiumStatusChanged() had the same early return.
  • MockStateService now exposes doesActiveAccountHavePremiumResult and doesAccountHavePremiumByUserId as independent knobs where production delegates one to the other; every call site in this PR sets the right one.
  • Task { await billingService.start() } in ServiceContainer runs in extensions too, but it mirrors the adjacent authenticatorSyncService.start() and only costs a publisher subscription plus two UserDefaults reads.
  • reconcileOnEachNewSync(userId:) subscribes to the active-account-scoped lastSyncTimePublisher() while the task is scoped to an explicit userId; window is transient and the subscriber is cancelled on account switch.
  • PremiumUpgradeStateStore and resolvedUserId(_:stateService:) are declared at module scope in the test target rather than private; cosmetic.

Comment thread BitwardenShared/Core/Billing/Services/BillingService.swift
Give BillingServiceTests its own MockBillingStateService for the
billingStateService: dependency instead of reusing MockStateService for
both parameters. MockStateService's BillingStateService-inherited
methods are now conformance-only stubs; a small PremiumUpgradeStateStore
plus a nil-resolves-to-active-account helper back the generated mock's
per-user-id state, since it can't otherwise represent two accounts at
once.
StateService inherited BillingStateService only because
VaultListProcessor reached billing-state methods through the general
HasStateService dependency, with no narrower seam available. Add
HasBillingStateService (mirroring the existing HasBillingRepository/
HasBillingService pattern) and point VaultListProcessor at it directly,
so StateService no longer needs to carry BillingStateService's
requirements at all.

This lets MockStateService drop every BillingStateService method and
backing property outright, rather than keeping conformance-only stubs
around. BillingRepositoryTests and the VaultListProcessor test files
move to MockBillingStateService for the billing-state pieces they need.
await services.billingService.shouldShowUpgradedToPremiumActionCard()

let isBannerDismissed = await services.stateService.isPremiumUpgradeBannerDismissed()
let isBannerDismissed = await services.billingStateService.isPremiumUpgradeBannerDismissed()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ DEBT: The premium-upgrade-banner flag is now read through billingStateService but still written through stateService.

Details

This line moves the read to services.billingStateService.isPremiumUpgradeBannerDismissed(), but the write for the same persisted key stays on the other dependency at VaultListProcessor.swift:363:

try await services.stateService.setPremiumUpgradeBannerDismissed(true)

(VaultGroupProcessor.swift:238 does the same.) isPremiumUpgradeBannerDismissed() lives on BillingStateService while setPremiumUpgradeBannerDismissed(_:userId:) is still declared on StateService (StateService.swift:587), so after this commit the same processor reaches the same premiumUpgradeBannerDismissed_<userId> key through two different injected protocols.

Production is unaffected — ServiceContainer supplies the same DefaultStateService for both. The cost is in tests: perform_dismissPremiumUpgradeActionCard asserts on stateService.premiumUpgradeBannerDismissedByUserId while the visibility tests configure billingStateService.isPremiumUpgradeBannerDismissedReturnValue, so a dismiss and a subsequent read can no longer contradict each other in a test the way they would in the app.

Moving setPremiumUpgradeBannerDismissed(_:userId:) onto BillingStateService alongside its getter would keep the pair together and finish the decoupling this commit started.

BillingServiceTests.swift had grown to 1023 lines with both
type_body_length and file_length disabled outright. Split it into:
- BillingServiceTests.swift: checkout, plan/subscription lookups,
  self-hosted detection, and premiumStatusChanged — none of which touch
  billingStateService.
- BillingService+BillingStateTests.swift: action cards, the
  subscription attention card, and premium-upgrade-pending
  reconciliation — everything that does, taking the
  PremiumUpgradeStateStore/resolvedUserId mock-wiring helpers with it
  since they're now only used here.

Follows the existing TypeName+ConcernTests.swift convention (separate
type, duplicated setup) already used for VaultListProcessor+BillingTests.swift.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-review Request a Claude code review app:password-manager Bitwarden Password Manager app context t:feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant