Skip to content

[PM-36969] feat: Surface subscription substate to premium gates#6931

Merged
SaintPatrck merged 18 commits into
mainfrom
premium-upgrade/pm-36969-subscription-status-flow
May 19, 2026
Merged

[PM-36969] feat: Surface subscription substate to premium gates#6931
SaintPatrck merged 18 commits into
mainfrom
premium-upgrade/pm-36969-subscription-status-flow

Conversation

@SaintPatrck
Copy link
Copy Markdown
Contributor

@SaintPatrck SaintPatrck commented May 15, 2026

🎟️ Tracking

📔 Objective

The server keeps Profile.Premium=true during the grace window after a subscription enters a recovery or terminal Stripe state. Surfaces that gate purely on isPremium either suppress the Plan-screen badge that would explain the user's situation, or hide the upgrade CTA users need to recover their account.

Adds PremiumStateManager.subscriptionStatusStateFlow (Loading/NoSubscription/Available/Error) so callers can derive "effectively premium" from both the account flag and the Stripe substate. The upgrade banner now flips back on when the active subscription is in a trouble state (past_due, update_payment, canceled, paused) even while the server still reports premium. The Plan screen routes free-with-trouble-substate users to the Premium view so they see the right badge and Manage/Resubscribe affordances.

Renames OVERDUE_PAYMENT → UPDATE_PAYMENT so the badge label matches the Figma frame. A new SubscriptionResult.NotFound maps the 404 returned for users without a GatewaySubscriptionId to "free, show upgrade CTA" instead of an error dialog.

📸 Screenshots

Figma Actual

@SaintPatrck SaintPatrck added the ai-review-vnext Request a Claude code review using the vNext workflow label May 15, 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 May 15, 2026
@SaintPatrck SaintPatrck removed the app:authenticator Bitwarden Authenticator app context label May 15, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 15, 2026

Bitwarden Claude Code Review

Overall Assessment: APPROVE

This PR surfaces Stripe subscription substate to the premium gates so users in past_due, update_payment, canceled, or paused states are routed correctly even when the server still reports Profile.Premium=true. I reviewed the new SubscriptionStatusState model, the PremiumStateManager.subscriptionStatusStateFlow (eager, keyed on active user, refreshed on push), the service-layer 404 → NotFound mapping, the OVERDUE_PAYMENTUPDATE_PAYMENT rename across UI/strings/tests, and the new annotatedPluralsResource helper plus the <annotation emphasis="bold"> spans that bold dates in the description strings. The change is layered cleanly (service maps HTTP, repo passes through, manager owns flow state, ViewModel observes and routes), prior reviewer feedback in the long thread history has been addressed in recent commits (whole-response assertions, dead NotFound throwable removed, formatting, AuthDiskSource.userStateFlow over AuthRepository), and test coverage is thorough across banner trouble-state combinations, push-triggered refetches, user-switch refetches, and the new bold-span rendering.

Code Review Details

No new findings. PM-37465 is acknowledged as the follow-up for the org-only-premium 404 flash from Premium to Free view.

@codecov
Copy link
Copy Markdown

codecov Bot commented May 15, 2026

Codecov Report

❌ Patch coverage is 91.28440% with 19 lines in your changes missing coverage. Please review.
✅ Project coverage is 86.17%. Comparing base (1f8280f) to head (6994f60).
⚠️ Report is 5 commits behind head on main.

Files with missing lines Patch % Lines
...arden/ui/platform/base/util/StringResExtensions.kt 76.47% 1 Missing and 7 partials ⚠️
.../ui/platform/feature/premium/plan/PlanViewModel.kt 91.07% 1 Missing and 4 partials ⚠️
...en/data/billing/manager/PremiumStateManagerImpl.kt 94.44% 0 Missing and 4 partials ⚠️
...den/ui/platform/feature/premium/plan/PlanScreen.kt 95.34% 0 Missing and 2 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #6931      +/-   ##
==========================================
- Coverage   86.25%   86.17%   -0.09%     
==========================================
  Files         909      873      -36     
  Lines       64380    63492     -888     
  Branches     9146     9189      +43     
==========================================
- Hits        55533    54714     -819     
+ Misses       5704     5619      -85     
- Partials     3143     3159      +16     
Flag Coverage Δ
app-data 17.09% <33.48%> (+0.23%) ⬆️
app-ui-auth-tools 19.16% <4.78%> (-0.39%) ⬇️
app-ui-platform 16.26% <55.50%> (+0.18%) ⬆️
app-ui-vault 27.95% <4.78%> (-0.84%) ⬇️
authenticator 6.25% <4.78%> (-0.06%) ⬇️
lib-core-network-bridge 4.06% <2.87%> (-0.06%) ⬇️
lib-data-ui 1.15% <12.44%> (+0.02%) ⬆️

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.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@github-actions github-actions Bot added the app:authenticator Bitwarden Authenticator app context label May 18, 2026
Comment thread ui/src/main/res/values/strings.xml Outdated
SaintPatrck and others added 5 commits May 18, 2026 17:26
The server keeps `Profile.Premium=true` during the grace window after a
subscription enters a recovery or terminal state, so callers that gate
purely on `isPremium` either route users away from the Plan-screen badge
that explains their situation (PM-36969, PM-36970, PM-37181) or suppress
the upgrade CTA users need to recover their account (PM-37093, PM-37177,
PM-37180). Expose the Stripe substate via a new
`PremiumStateManager.subscriptionStatusStateFlow` and use it to compute
"effectively premium" for banner eligibility and Plan-screen routing.

Renames the misleading `OVERDUE_PAYMENT` enum value to `UPDATE_PAYMENT` so
the badge label matches Figma. The subscription endpoint 404s for free
users (no `GatewaySubscriptionId`); a new `SubscriptionResult.NotFound`
maps that case to "free, show upgrade CTA" instead of an error dialog.
Avoids a guaranteed 404 round-trip to GET /accounts/subscription for
every free user on app launch and on every premium-status push. Re-keys
on (userId, isPremium) so the flow refetches when an account transitions
to premium or when a push arrives for an already-premium user.
The isPremium gate prevented users whose Stripe subscription had moved
to canceled / incomplete_expired (with the server flipping isPremium to
false) from reaching the Premium view in Settings. They landed on the
Free view with no way to see their canceled status or resubscribe.

Drop the gate so the subscription fetch runs whenever there is an
active user; the response layer already translates 404 to
NoSubscription, so genuine free users still bypass the Premium view.
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
The "Apply suggestions from code review" commit accidentally pasted the
property declaration twice, breaking compile. Restore the single
declaration and move the fetch-keying / 404 details onto the
implementation so the interface only carries the caller-facing contract.
@SaintPatrck SaintPatrck force-pushed the premium-upgrade/pm-36969-subscription-status-flow branch from ad72ae6 to d9f1828 Compare May 18, 2026 21:32
Design calls for the date in each Plan-screen subscription-status
description to be visually emphasized so users can quickly spot when
their next charge, cancellation, or grace period takes effect.
Standardize on the codebase's existing annotation-tag pattern
(`<annotation emphasis="bold"><annotation arg="N">`) rather than
inventing a new mechanism. Premium ViewState now carries raw data
fields so the Composable can render an AnnotatedString with the
appropriate bold span per subscription status.
@SaintPatrck SaintPatrck force-pushed the premium-upgrade/pm-36969-subscription-status-flow branch from d9f1828 to c5990ff Compare May 18, 2026 21:37
The manager only needs the active user id and the raw premium flags
from the persisted profile; routing through AuthRepository.userStateFlow
just to read derived UserState.Account fields adds an unnecessary layer
and a heavier dependency. Read `hasPremiumPersonally` /
`hasPremiumFromOrganization` / `creationDate` straight off the disk-side
profile and drop the AuthRepository dependency entirely.
HTTP status inspection is a network-layer concern. Surfacing the typed
NotFound sentinel from the service keeps the repository focused on
domain mapping and matches the GetCipherResponse / SendsServiceImpl
precedent for endpoints that treat 404 as a non-error.
When the subscription endpoint returned NotFound, the loading dialog
was dismissed before the pricing fetch landed, briefly exposing the
placeholder rate ("--") on the Free view. Hold the loading overlay
through the pricing fetch so the rate never visibly flashes.
…anagerTest

The test exercises the PremiumStateManager contract via its default
implementation; the file and class name now reflect that scope rather
than the implementation type.
@SaintPatrck SaintPatrck marked this pull request as ready for review May 19, 2026 16:32
@SaintPatrck SaintPatrck requested a review from a team as a code owner May 19, 2026 16:32
dispatcherManager: DispatcherManager,
): PremiumStateManager = PremiumStateManagerImpl(
authDiskSource = authDiskSource,
authRepository = authRepository,
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.

👍

PremiumSubscriptionStatus.PAST_DUE,
PremiumSubscriptionStatus.PAUSED,
PremiumSubscriptionStatus.UPDATE_PAYMENT,
-> true
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.

Make sure to run the auto-formatter here too

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.

This has not been updated

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.

Oops. Got click happy. Updated.


@Suppress("LargeClass")
class PremiumStateManagerImplTest {
class PremiumStateManagerTest {
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.

👍

@SaintPatrck SaintPatrck force-pushed the premium-upgrade/pm-36969-subscription-status-flow branch from 1c71c49 to 5e84c04 Compare May 19, 2026 18:44
@SaintPatrck SaintPatrck enabled auto-merge May 19, 2026 20:38
…ption-status-flow' into premium-upgrade/pm-36969-subscription-status-flow
server.enqueue(response)
val actual = service.getSubscription()
assertTrue(actual.isSuccess)
assertTrue(actual.getOrNull() is GetSubscriptionResponse.NotFound)
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't we just assert the whole response:

assertEquals(actual, GetSubscriptionResponse.NotFound)

val actual = service.getSubscription()
assertEquals(
CadenceTypeJson.MONTHLY,
actual.getOrNull()?.cart?.cadence,
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't we just assert the whole thing?

server.enqueue(response)
val actual = service.getSubscription()
assertTrue(actual.isSuccess)
assertTrue(actual.getOrNull() is GetSubscriptionResponse.Success)
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 use assertEquals

NotFound's throwable was never read by any consumer, which forced the
404 test to fall back to type-only assertions. Converting NotFound to a
data object lets tests assert the entire response via assertEquals and
removes carrying-state-no-one-uses from the model.
@SaintPatrck SaintPatrck added this pull request to the merge queue May 19, 2026
@github-merge-queue github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch May 19, 2026
@SaintPatrck SaintPatrck added this pull request to the merge queue May 19, 2026
Merged via the queue into main with commit 5f3f9d1 May 19, 2026
25 checks passed
@SaintPatrck SaintPatrck deleted the premium-upgrade/pm-36969-subscription-status-flow branch May 19, 2026 22:31
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