1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-17 18:09:17 +00:00

[PM-30908]Correct Premium subscription status handling (#18475)

* Implement the required changes

* Fix the family plan creation for expired sub

* Resolve the pr comments

* resolve the resubscribe issue

* Removed redirectOnCompletion: true from the resubscribe

* Display the Change payment method dialog on the subscription page

* adjust the page reload time

* revert payment method open in subscription page

* Enable cancel premium see the subscription page

* Revert the removal of hasPremiumPersonally

* remove extra space

* Add can view subscription

* Use the canViewSubscription

* Resolve the tab default to premium

* use the subscription Instead of hasPremium

* Revert the changes on user-subscription

* Use the flag to redirect to subscription page

* revert the canViewSubscription change

* resolve the route issue with premium

* Change the path to

* Revert the previous iteration changes

* Fix the build error
This commit is contained in:
cyprain-okeke
2026-02-13 18:56:35 +01:00
committed by GitHub
parent ab0739b693
commit f46511b3e8
9 changed files with 153 additions and 25 deletions

View File

@@ -4,6 +4,8 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { ProductTierType } from "@bitwarden/common/billing/enums";
import { BitwardenSubscriptionResponse } from "@bitwarden/common/billing/models/response/bitwarden-subscription.response";
import { SubscriptionCadence } from "@bitwarden/common/billing/types/subscription-pricing-tier";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { Maybe } from "@bitwarden/pricing";
import { BitwardenSubscription } from "@bitwarden/subscription";
import {
@@ -23,11 +25,18 @@ export class AccountBillingClient {
return this.apiService.send("GET", path, null, true, true);
};
getSubscription = async (): Promise<BitwardenSubscription> => {
getSubscription = async (): Promise<Maybe<BitwardenSubscription>> => {
const path = `${this.endpoint}/subscription`;
const json = await this.apiService.send("GET", path, null, true, true);
const response = new BitwardenSubscriptionResponse(json);
return response.toDomain();
try {
const json = await this.apiService.send("GET", path, null, true, true);
const response = new BitwardenSubscriptionResponse(json);
return response.toDomain();
} catch (error: any) {
if (error instanceof ErrorResponse && error.statusCode === 404) {
return null;
}
throw error;
}
};
purchaseSubscription = async (

View File

@@ -19,7 +19,7 @@ const routes: Routes = [
component: SubscriptionComponent,
data: { titleId: "subscription" },
children: [
{ path: "", pathMatch: "full", redirectTo: "premium" },
{ path: "", pathMatch: "full", redirectTo: "user-subscription" },
...featureFlaggedRoute({
defaultComponent: UserSubscriptionComponent,
flaggedComponent: AccountSubscriptionComponent,

View File

@@ -1,17 +1,22 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, OnInit } from "@angular/core";
import { Observable, switchMap } from "rxjs";
import { combineLatest, from, map, Observable, switchMap } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { AccountBillingClient } from "../clients/account-billing.client";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "subscription.component.html",
standalone: false,
providers: [AccountBillingClient],
})
export class SubscriptionComponent implements OnInit {
hasPremium$: Observable<boolean>;
@@ -21,9 +26,21 @@ export class SubscriptionComponent implements OnInit {
private platformUtilsService: PlatformUtilsService,
billingAccountProfileStateService: BillingAccountProfileStateService,
accountService: AccountService,
configService: ConfigService,
private accountBillingClient: AccountBillingClient,
) {
this.hasPremium$ = accountService.activeAccount$.pipe(
switchMap((account) => billingAccountProfileStateService.hasPremiumPersonally$(account.id)),
this.hasPremium$ = combineLatest([
configService.getFeatureFlag$(FeatureFlag.PM29594_UpdateIndividualSubscriptionPage),
accountService.activeAccount$,
]).pipe(
switchMap(([isFeatureFlagEnabled, account]) => {
if (isFeatureFlagEnabled) {
return from(accountBillingClient.getSubscription()).pipe(
map((subscription) => !!subscription),
);
}
return billingAccountProfileStateService.hasPremiumPersonally$(account.id);
}),
);
}

View File

@@ -34,6 +34,11 @@ import {
AdjustAccountSubscriptionStorageDialogComponent,
AdjustAccountSubscriptionStorageDialogParams,
} from "@bitwarden/web-vault/app/billing/individual/subscription/adjust-account-subscription-storage-dialog.component";
import {
UnifiedUpgradeDialogComponent,
UnifiedUpgradeDialogStatus,
UnifiedUpgradeDialogStep,
} from "@bitwarden/web-vault/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component";
import {
OffboardingSurveyDialogResultType,
openOffboardingSurvey,
@@ -93,10 +98,11 @@ export class AccountSubscriptionComponent {
if (!this.account()) {
return await redirectToPremiumPage();
}
if (!this.hasPremiumPersonally()) {
const subscription = await this.accountBillingClient.getSubscription();
if (!subscription) {
return await redirectToPremiumPage();
}
return await this.accountBillingClient.getSubscription();
return subscription;
},
});
@@ -106,6 +112,7 @@ export class AccountSubscriptionComponent {
const subscription = this.subscription.value();
if (subscription) {
return (
subscription.status === SubscriptionStatuses.Incomplete ||
subscription.status === SubscriptionStatuses.IncompleteExpired ||
subscription.status === SubscriptionStatuses.Canceled ||
subscription.status === SubscriptionStatuses.Unpaid
@@ -230,6 +237,27 @@ export class AccountSubscriptionComponent {
case SubscriptionCardActions.UpdatePayment:
await this.router.navigate(["../payment-details"], { relativeTo: this.activatedRoute });
break;
case SubscriptionCardActions.Resubscribe: {
const account = this.account();
if (!account) {
return;
}
const dialogRef = UnifiedUpgradeDialogComponent.open(this.dialogService, {
data: {
account,
initialStep: UnifiedUpgradeDialogStep.Payment,
selectedPlan: PersonalSubscriptionPricingTierIds.Premium,
},
});
const result = await lastValueFrom(dialogRef.closed);
if (result?.status === UnifiedUpgradeDialogStatus.UpgradedToPremium) {
this.subscription.reload();
}
break;
}
case SubscriptionCardActions.UpgradePlan:
await this.openUpgradeDialog();
break;

View File

@@ -3338,6 +3338,15 @@
"reinstated": {
"message": "The subscription has been reinstated."
},
"resubscribe": {
"message": "Resubscribe"
},
"yourSubscriptionIsExpired": {
"message": "Your subscription is expired"
},
"yourSubscriptionIsCanceled": {
"message": "Your subscription is canceled"
},
"cancelConfirmation": {
"message": "Are you sure you want to cancel? You will lose access to all of this subscription's features at the end of this billing cycle."
},

View File

@@ -78,6 +78,7 @@ type SubscriptionCardAction =
| "contact-support"
| "manage-invoices"
| "reinstate-subscription"
| "resubscribe"
| "update-payment"
| "upgrade-plan";
```
@@ -279,7 +280,7 @@ Payment issue expired, subscription has been suspended:
</billing-subscription-card>
```
**Actions available:** Contact Support
**Actions available:** Resubscribe
### Past Due
@@ -370,7 +371,7 @@ Subscription that has been canceled:
</billing-subscription-card>
```
**Note:** Canceled subscriptions display no callout or actions.
**Actions available:** Resubscribe
### Enterprise

View File

@@ -44,9 +44,11 @@ describe("SubscriptionCardComponent", () => {
unpaid: "Unpaid",
weCouldNotProcessYourPayment: "We could not process your payment",
contactSupportShort: "Contact support",
yourSubscriptionHasExpired: "Your subscription has expired",
yourSubscriptionIsExpired: "Your subscription is expired",
yourSubscriptionIsCanceled: "Your subscription is canceled",
yourSubscriptionIsScheduledToCancel: `Your subscription is scheduled to cancel on ${params[0]}`,
reinstateSubscription: "Reinstate subscription",
resubscribe: "Resubscribe",
upgradeYourPlan: "Upgrade your plan",
premiumShareEvenMore: "Premium share even more",
upgradeNow: "Upgrade now",
@@ -253,7 +255,7 @@ describe("SubscriptionCardComponent", () => {
expect(buttons[1].nativeElement.textContent.trim()).toBe("Contact support");
});
it("should display incomplete_expired callout with contact support action", () => {
it("should display incomplete_expired callout with resubscribe action", () => {
setupComponent({
...baseSubscription,
status: "incomplete_expired",
@@ -265,18 +267,18 @@ describe("SubscriptionCardComponent", () => {
expect(calloutData).toBeTruthy();
expect(calloutData!.type).toBe("danger");
expect(calloutData!.title).toBe("Expired");
expect(calloutData!.description).toContain("Your subscription has expired");
expect(calloutData!.description).toContain("Your subscription is expired");
expect(calloutData!.callsToAction?.length).toBe(1);
const callout = fixture.debugElement.query(By.css("bit-callout"));
expect(callout).toBeTruthy();
const description = callout.query(By.css("p"));
expect(description.nativeElement.textContent).toContain("Your subscription has expired");
expect(description.nativeElement.textContent).toContain("Your subscription is expired");
const buttons = callout.queryAll(By.css("button"));
expect(buttons.length).toBe(1);
expect(buttons[0].nativeElement.textContent.trim()).toBe("Contact support");
expect(buttons[0].nativeElement.textContent.trim()).toBe("Resubscribe");
});
it("should display pending cancellation callout for active status with cancelAt", () => {
@@ -364,15 +366,29 @@ describe("SubscriptionCardComponent", () => {
expect(buttons[0].nativeElement.textContent.trim()).toBe("Manage invoices");
});
it("should not display callout for canceled status", () => {
it("should display canceled callout with resubscribe action", () => {
setupComponent({
...baseSubscription,
status: "canceled",
canceled: new Date("2025-01-15"),
});
const calloutData = component.callout();
expect(calloutData).toBeTruthy();
expect(calloutData!.type).toBe("danger");
expect(calloutData!.title).toBe("Canceled");
expect(calloutData!.description).toContain("Your subscription is canceled");
expect(calloutData!.callsToAction?.length).toBe(1);
const callout = fixture.debugElement.query(By.css("bit-callout"));
expect(callout).toBeFalsy();
expect(callout).toBeTruthy();
const description = callout.query(By.css("p"));
expect(description.nativeElement.textContent).toContain("Your subscription is canceled");
const buttons = callout.queryAll(By.css("button"));
expect(buttons.length).toBe(1);
expect(buttons[0].nativeElement.textContent.trim()).toBe("Resubscribe");
});
it("should display unpaid callout with manage invoices action", () => {
@@ -489,6 +505,39 @@ describe("SubscriptionCardComponent", () => {
expect(emitSpy).toHaveBeenCalledWith("manage-invoices");
});
it("should emit resubscribe action when button is clicked for incomplete_expired status", () => {
setupComponent({
...baseSubscription,
status: "incomplete_expired",
suspension: new Date("2025-01-15"),
gracePeriod: 7,
});
const emitSpy = jest.spyOn(component.callToActionClicked, "emit");
const button = fixture.debugElement.query(By.css("bit-callout button"));
button.triggerEventHandler("click", { button: 0 });
fixture.detectChanges();
expect(emitSpy).toHaveBeenCalledWith("resubscribe");
});
it("should emit resubscribe action when button is clicked for canceled status", () => {
setupComponent({
...baseSubscription,
status: "canceled",
canceled: new Date("2025-01-15"),
});
const emitSpy = jest.spyOn(component.callToActionClicked, "emit");
const button = fixture.debugElement.query(By.css("bit-callout button"));
button.triggerEventHandler("click", { button: 0 });
fixture.detectChanges();
expect(emitSpy).toHaveBeenCalledWith("resubscribe");
});
});
describe("Cart summary header content", () => {

View File

@@ -51,10 +51,13 @@ export default {
weCouldNotProcessYourPayment:
"We could not process your payment. Please update your payment method or contact the support team for assistance.",
contactSupportShort: "Contact Support",
yourSubscriptionHasExpired:
"Your subscription has expired. Please contact the support team for assistance.",
yourSubscriptionIsExpired:
"Your subscription is expired. Please resubscribe to continue using premium features.",
yourSubscriptionIsCanceled:
"Your subscription is canceled. Please resubscribe to continue using premium features.",
yourSubscriptionIsScheduledToCancel: `Your subscription is scheduled to cancel on ${args[0]}. You can reinstate it anytime before then.`,
reinstateSubscription: "Reinstate subscription",
resubscribe: "Resubscribe",
upgradeYourPlan: "Upgrade your plan",
premiumShareEvenMore:
"Share even more with Families, or get powerful, trusted password security with Teams or Enterprise.",

View File

@@ -20,6 +20,7 @@ export const SubscriptionCardActions = {
ContactSupport: "contact-support",
ManageInvoices: "manage-invoices",
ReinstateSubscription: "reinstate-subscription",
Resubscribe: "resubscribe",
UpdatePayment: "update-payment",
UpgradePlan: "upgrade-plan",
} as const;
@@ -154,12 +155,12 @@ export class SubscriptionCardComponent {
return {
title: this.i18nService.t("expired"),
type: "danger",
description: this.i18nService.t("yourSubscriptionHasExpired"),
description: this.i18nService.t("yourSubscriptionIsExpired"),
callsToAction: [
{
text: this.i18nService.t("contactSupportShort"),
text: this.i18nService.t("resubscribe"),
buttonType: "unstyled",
action: SubscriptionCardActions.ContactSupport,
action: SubscriptionCardActions.Resubscribe,
},
],
};
@@ -218,7 +219,18 @@ export class SubscriptionCardComponent {
};
}
case SubscriptionStatuses.Canceled: {
return null;
return {
title: this.i18nService.t("canceled"),
type: "danger",
description: this.i18nService.t("yourSubscriptionIsCanceled"),
callsToAction: [
{
text: this.i18nService.t("resubscribe"),
buttonType: "unstyled",
action: SubscriptionCardActions.Resubscribe,
},
],
};
}
case SubscriptionStatuses.Unpaid: {
return {