[PM-36969] feat: Surface subscription substate to premium gates#6931
Conversation
Bitwarden Claude Code ReviewOverall Assessment: APPROVE This PR surfaces Stripe subscription substate to the premium gates so users in Code Review DetailsNo new findings. PM-37465 is acknowledged as the follow-up for the org-only-premium 404 flash from Premium to Free view. |
Codecov Report❌ Patch coverage is 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
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
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.
ad72ae6 to
d9f1828
Compare
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.
d9f1828 to
c5990ff
Compare
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.
| dispatcherManager: DispatcherManager, | ||
| ): PremiumStateManager = PremiumStateManagerImpl( | ||
| authDiskSource = authDiskSource, | ||
| authRepository = authRepository, |
| PremiumSubscriptionStatus.PAST_DUE, | ||
| PremiumSubscriptionStatus.PAUSED, | ||
| PremiumSubscriptionStatus.UPDATE_PAYMENT, | ||
| -> true |
There was a problem hiding this comment.
Make sure to run the auto-formatter here too
There was a problem hiding this comment.
This has not been updated
There was a problem hiding this comment.
Oops. Got click happy. Updated.
|
|
||
| @Suppress("LargeClass") | ||
| class PremiumStateManagerImplTest { | ||
| class PremiumStateManagerTest { |
1c71c49 to
5e84c04
Compare
…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) |
There was a problem hiding this comment.
Can't we just assert the whole response:
assertEquals(actual, GetSubscriptionResponse.NotFound)| val actual = service.getSubscription() | ||
| assertEquals( | ||
| CadenceTypeJson.MONTHLY, | ||
| actual.getOrNull()?.cart?.cadence, |
There was a problem hiding this comment.
Can't we just assert the whole thing?
| server.enqueue(response) | ||
| val actual = service.getSubscription() | ||
| assertTrue(actual.isSuccess) | ||
| assertTrue(actual.getOrNull() is GetSubscriptionResponse.Success) |
There was a problem hiding this comment.
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.
🎟️ Tracking
📔 Objective
The server keeps
Profile.Premium=trueduring the grace window after a subscription enters a recovery or terminal Stripe state. Surfaces that gate purely onisPremiumeither 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_PAYMENTso the badge label matches the Figma frame. A newSubscriptionResult.NotFoundmaps the 404 returned for users without aGatewaySubscriptionIdto "free, show upgrade CTA" instead of an error dialog.📸 Screenshots