From f46511b3e86b6d7c2ae4d995d464b00f52177c4b Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Fri, 13 Feb 2026 18:56:35 +0100 Subject: [PATCH] [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 --- .../billing/clients/account-billing.client.ts | 17 +++-- .../individual-billing-routing.module.ts | 2 +- .../individual/subscription.component.ts | 23 ++++++- .../account-subscription.component.ts | 32 +++++++++- apps/web/src/locales/en/messages.json | 9 +++ .../subscription-card.component.mdx | 5 +- .../subscription-card.component.spec.ts | 63 ++++++++++++++++--- .../subscription-card.component.stories.ts | 7 ++- .../subscription-card.component.ts | 20 ++++-- 9 files changed, 153 insertions(+), 25 deletions(-) diff --git a/apps/web/src/app/billing/clients/account-billing.client.ts b/apps/web/src/app/billing/clients/account-billing.client.ts index 1334ff643dd..6864e1de981 100644 --- a/apps/web/src/app/billing/clients/account-billing.client.ts +++ b/apps/web/src/app/billing/clients/account-billing.client.ts @@ -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 => { + getSubscription = async (): Promise> => { 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 ( diff --git a/apps/web/src/app/billing/individual/individual-billing-routing.module.ts b/apps/web/src/app/billing/individual/individual-billing-routing.module.ts index f85dab54fe7..8d9c999caec 100644 --- a/apps/web/src/app/billing/individual/individual-billing-routing.module.ts +++ b/apps/web/src/app/billing/individual/individual-billing-routing.module.ts @@ -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, diff --git a/apps/web/src/app/billing/individual/subscription.component.ts b/apps/web/src/app/billing/individual/subscription.component.ts index 37fb2baf3a6..4f52f3c2ea2 100644 --- a/apps/web/src/app/billing/individual/subscription.component.ts +++ b/apps/web/src/app/billing/individual/subscription.component.ts @@ -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; @@ -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); + }), ); } diff --git a/apps/web/src/app/billing/individual/subscription/account-subscription.component.ts b/apps/web/src/app/billing/individual/subscription/account-subscription.component.ts index d8e25de7965..7fdc830effd 100644 --- a/apps/web/src/app/billing/individual/subscription/account-subscription.component.ts +++ b/apps/web/src/app/billing/individual/subscription/account-subscription.component.ts @@ -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; diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 59f5bc88419..d70a2d88bae 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -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." }, diff --git a/libs/subscription/src/components/subscription-card/subscription-card.component.mdx b/libs/subscription/src/components/subscription-card/subscription-card.component.mdx index c9cc6df7263..d3bad6583f5 100644 --- a/libs/subscription/src/components/subscription-card/subscription-card.component.mdx +++ b/libs/subscription/src/components/subscription-card/subscription-card.component.mdx @@ -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: ``` -**Actions available:** Contact Support +**Actions available:** Resubscribe ### Past Due @@ -370,7 +371,7 @@ Subscription that has been canceled: ``` -**Note:** Canceled subscriptions display no callout or actions. +**Actions available:** Resubscribe ### Enterprise diff --git a/libs/subscription/src/components/subscription-card/subscription-card.component.spec.ts b/libs/subscription/src/components/subscription-card/subscription-card.component.spec.ts index cdb85360c74..f524c4b5c26 100644 --- a/libs/subscription/src/components/subscription-card/subscription-card.component.spec.ts +++ b/libs/subscription/src/components/subscription-card/subscription-card.component.spec.ts @@ -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", () => { diff --git a/libs/subscription/src/components/subscription-card/subscription-card.component.stories.ts b/libs/subscription/src/components/subscription-card/subscription-card.component.stories.ts index 32976c89cc2..3d99ded2e5c 100644 --- a/libs/subscription/src/components/subscription-card/subscription-card.component.stories.ts +++ b/libs/subscription/src/components/subscription-card/subscription-card.component.stories.ts @@ -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.", diff --git a/libs/subscription/src/components/subscription-card/subscription-card.component.ts b/libs/subscription/src/components/subscription-card/subscription-card.component.ts index ebfb41df6c2..78d2c40eb3e 100644 --- a/libs/subscription/src/components/subscription-card/subscription-card.component.ts +++ b/libs/subscription/src/components/subscription-card/subscription-card.component.ts @@ -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 {