Skip to content

[PM-33946] feat: Add dynamic pricing and fix checkout flow#6793

Draft
SaintPatrck wants to merge 6 commits intomainfrom
premium-upgrade/PM-33946-dynamic-pricing-impl
Draft

[PM-33946] feat: Add dynamic pricing and fix checkout flow#6793
SaintPatrck wants to merge 6 commits intomainfrom
premium-upgrade/PM-33946-dynamic-pricing-impl

Conversation

@SaintPatrck
Copy link
Copy Markdown
Contributor

🎟️ Tracking

https://bitwarden.atlassian.net/browse/PM-33946

📔 Objective

Replace the static placeholder rate with dynamic pricing from the plans API, and implement correct checkout result handling for the premium upgrade flow.

Dynamic pricing:

  • Fetch pricing from GET /api/plans/premium on PlanScreen load
  • Show loading and error states when pricing is unavailable
  • Format the monthly rate from the annual plan price

Checkout result handling:

  • Correct the callback URL to bitwarden://premium-checkout-result to match the server-configured Stripe redirect (PM-34173)
  • Parse ?result=success / ?result=canceled query parameter via PremiumCheckoutCallbackResult sealed class, following the existing CookieCallbackResult pattern
  • Observe specialCircumstanceStateFlow at runtime to detect when the user returns from checkout
  • On success: show syncing state, trigger syncForResult(), navigate back with snackbar if premium is detected, or show "Upgrade pending" dialog with Sync now / Continue options if not yet provisioned
  • On canceled: show "Payment not received yet" dialog per Figma designs
  • Track checkout return state via isAwaitingPremiumStatus on ViewState.Free

📸 Screenshots

Add GET /plans/premium endpoint to fetch premium plan pricing dynamically
instead of using hardcoded placeholder values. The ViewModel now starts in
a Loading state, fetches pricing on init, and transitions to Free (with
formatted monthly rate) or Error (with retry) based on the API result.
- Restructure handlePricingResultReceive to use onFreeContent wrapper,
  preserving checkoutUrl on success
- Add ClosePricingErrorClick action that clears dialog and navigates back
- Wire GetPricingError dialog dismiss to ClosePricingErrorClick with
  "Close" button text instead of "Okay"
- Fix stale preview rate ($1.65 -> $1.67) and test assertions to use
  Free(rate="--") with GetPricingError dialog instead of ViewState.Error
- Remove unused DEFAULT_LOADING_STATE test constant
@SaintPatrck SaintPatrck added the ai-review-vnext Request a Claude code review using the vNext workflow label Apr 13, 2026
@github-actions github-actions bot added app:password-manager Bitwarden Password Manager app context app:authenticator Bitwarden Authenticator app context t:feature Change Type - Feature Development labels Apr 13, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 13, 2026

Bitwarden Claude Code Review

Overall Assessment: APPROVE

This PR replaces static placeholder premium pricing with dynamic data fetched from the plans API and implements correct checkout result handling for the premium upgrade flow. The changes span the network layer (new getPremiumPlan endpoint), the billing repository, and the PlanViewModel with new dialog states for pricing errors, pending upgrades, and checkout cancellation. The callback URL is corrected to bitwarden://premium-checkout-result and the result is now parsed via a PremiumCheckoutCallbackResult sealed class. Test coverage is thorough across all layers.

Code Review Details
  • ⚠️ IMPORTANT: Sync failure is not distinguished from "premium not yet provisioned" -- handleSyncCompleteReceive() ignores the SyncVaultDataResult, so a network error shows the same "Upgrade pending" dialog as a successful sync where premium hasn't propagated yet
    • PlanViewModel.kt:309-330

@codecov
Copy link
Copy Markdown

codecov bot commented Apr 13, 2026

Codecov Report

❌ Patch coverage is 87.30159% with 24 lines in your changes missing coverage. Please review.
✅ Project coverage is 85.69%. Comparing base (287d8a9) to head (4a6de0d).

Files with missing lines Patch % Lines
.../ui/platform/feature/premium/plan/PlanViewModel.kt 84.55% 10 Missing and 11 partials ⚠️
...itwarden/data/billing/util/PremiumCheckoutUtils.kt 77.77% 2 Missing ⚠️
...en/ui/platform/feature/rootnav/RootNavViewModel.kt 0.00% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #6793      +/-   ##
==========================================
+ Coverage   85.00%   85.69%   +0.68%     
==========================================
  Files         971      828     -143     
  Lines       61312    58730    -2582     
  Branches     8647     8595      -52     
==========================================
- Hits        52121    50327    -1794     
+ Misses       6190     5422     -768     
+ Partials     3001     2981      -20     
Flag Coverage Δ
app-data 17.46% <6.87%> (-0.27%) ⬇️
app-ui-auth-tools 20.34% <5.82%> (-0.39%) ⬇️
app-ui-platform 15.42% <77.77%> (-0.51%) ⬇️
app-ui-vault 25.94% <0.00%> (-0.67%) ⬇️
authenticator 6.53% <0.00%> (-0.02%) ⬇️
lib-core-network-bridge 4.27% <1.58%> (-0.01%) ⬇️
lib-data-ui 1.03% <0.00%> (-0.01%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 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.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 13, 2026

Logo
Checkmarx One – Scan Summary & Detailse50eb582-1609-4872-93a0-8c2dc77454bb

Great job! No new security vulnerabilities introduced in this pull request

}
}

private fun handlePremiumUpgradeSuccess() {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Can we drop the handle prefix since this is not for a specific action.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Changed to onPremiumUpgradeSuccess

Comment on lines +309 to 330
private fun handleSyncCompleteReceive() {
onFreeContent { freeState ->
if (!freeState.isAwaitingPremiumStatus) return@onFreeContent

val isPremium = authRepository
.userStateFlow
.value
?.activeAccount
?.isPremium == true
if (isPremium) {
onPremiumUpgradeSuccess()
} else {
// Sync completed but premium not yet provisioned —
// prompt the user to retry or continue as free.
mutableStateFlow.update {
it.copy(
dialogState = PlanState.DialogState.PendingUpgrade,
)
}
}
}
}
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.

⚠️ IMPORTANT: Sync failure is not distinguished from "premium not yet provisioned"

Details and fix

handleSyncCompleteReceive() ignores the SyncVaultDataResult carried by SyncCompleteReceive. When sync fails (e.g., network error), the code falls through to the else branch and shows the "Upgrade pending" dialog, telling the user their upgrade is being processed when the actual problem is a failed sync.

Consider checking the result and showing an appropriate error when sync fails:

private fun handleSyncCompleteReceive(action: PlanAction.Internal.SyncCompleteReceive) {
    onFreeContent { freeState ->
        if (!freeState.isAwaitingPremiumStatus) return@onFreeContent

        when (action.result) {
            is SyncVaultDataResult.Error -> {
                // Sync failed — show PendingUpgrade so user can retry
                // or consider a distinct error dialog
                mutableStateFlow.update {
                    it.copy(
                        dialogState = PlanState.DialogState.PendingUpgrade,
                    )
                }
            }
            is SyncVaultDataResult.Success -> {
                val isPremium = authRepository
                    .userStateFlow
                    .value
                    ?.activeAccount
                    ?.isPremium == true
                if (isPremium) {
                    onPremiumUpgradeSuccess()
                } else {
                    mutableStateFlow.update {
                        it.copy(
                            dialogState = PlanState.DialogState.PendingUpgrade,
                        )
                    }
                }
            }
        }
    }
}

At minimum, the action's result should be forwarded to the handler so the failure path can be handled (or explicitly acknowledged as intentional). The current behavior may mislead the user on a flaky connection.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@claude The current behavior is intentional. Both sync failure and "premium not yet provisioned" present the same recovery path — the user can tap "Sync now" to retry or "Continue" to move on. A distinct sync error dialog wouldn't change the available actions, so the extra state adds complexity without UX benefit.

Copy link
Copy Markdown
Contributor

@claude claude bot Apr 14, 2026

Choose a reason for hiding this comment

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

Claude finished @SaintPatrck's task in 11s —— View job


Thanks for the explanation, @SaintPatrck. That makes sense — since both sync failure and "not yet provisioned" offer the same "Sync now" / "Continue" recovery actions, adding a distinct error dialog would just be extra state without changing the UX. Acknowledged as intentional. 👍

MutableStateFlow<SpecialCircumstance?>(null)
private val mockSpecialCircumstanceManager: SpecialCircumstanceManager =
mockk(relaxed = true) {
every { specialCircumstance } returns null
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Should we update this this too?

every { specialCircumstance } answers { mutableSpecialCircumstanceStateFlow.value }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-review-vnext Request a Claude code review using the vNext workflow app:authenticator Bitwarden Authenticator app context app:password-manager Bitwarden Password Manager app context t:feature Change Type - Feature Development

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants