From 906feae53c797189f0db7f840cbb5a8528397f26 Mon Sep 17 00:00:00 2001 From: Cy Okeke Date: Wed, 21 Jan 2026 11:55:16 +0100 Subject: [PATCH] Implement the required changes --- .../billing/clients/account-billing.client.ts | 17 +++-- .../account-subscription.component.ts | 34 ++++++++-- 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 ++++-- 7 files changed, 131 insertions(+), 24 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 e520e70bf70..b421faf0cff 100644 --- a/apps/web/src/app/billing/clients/account-billing.client.ts +++ b/apps/web/src/app/billing/clients/account-billing.client.ts @@ -2,6 +2,8 @@ import { Injectable } from "@angular/core"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { BitwardenSubscriptionResponse } from "@bitwarden/common/billing/models/response/bitwarden-subscription.response"; +import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; +import { Maybe } from "@bitwarden/pricing"; import { BitwardenSubscription } from "@bitwarden/subscription"; import { @@ -21,11 +23,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/subscription/account-subscription.component.ts b/apps/web/src/app/billing/individual/subscription/account-subscription.component.ts index 183f4f82666..65c60ccaee2 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,10 @@ import { AdjustAccountSubscriptionStorageDialogComponent, AdjustAccountSubscriptionStorageDialogParams, } from "@bitwarden/web-vault/app/billing/individual/subscription/adjust-account-subscription-storage-dialog.component"; +import { + UnifiedUpgradeDialogComponent, + UnifiedUpgradeDialogStatus, +} from "@bitwarden/web-vault/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component"; import { OffboardingSurveyDialogResultType, openOffboardingSurvey, @@ -75,13 +79,11 @@ export class AccountSubscriptionComponent { if (!account) { return await redirectToPremiumPage(); } - const hasPremiumPersonally = await firstValueFrom( - this.billingAccountProfileStateService.hasPremiumPersonally$(account.id), - ); - if (!hasPremiumPersonally) { + const subscription = await this.accountBillingClient.getSubscription(); + if (!subscription) { return await redirectToPremiumPage(); } - return await this.accountBillingClient.getSubscription(); + return subscription; }, }); @@ -91,6 +93,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 @@ -208,6 +211,27 @@ export class AccountSubscriptionComponent { case SubscriptionCardActions.UpdatePayment: await this.router.navigate(["../payment-details"], { relativeTo: this.activatedRoute }); break; + case SubscriptionCardActions.Resubscribe: { + const account = await firstValueFrom(this.accountService.activeAccount$); + if (!account) { + return; + } + + const dialogRef = UnifiedUpgradeDialogComponent.open(this.dialogService, { + data: { + account, + planSelectionStepTitleOverride: "upgradeYourPlan", + hideContinueWithoutUpgradingButton: true, + }, + }); + + const result = await lastValueFrom(dialogRef.closed); + + if (result?.status === UnifiedUpgradeDialogStatus.UpgradedToPremium) { + this.subscription.reload(); + } + break; + } case SubscriptionCardActions.UpgradePlan: // TODO: Implement upgrade plan navigation break; diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index ecad9f8a624..46fed4eb0e3 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -3263,6 +3263,15 @@ "reinstated": { "message": "The subscription has been reinstated." }, + "resubscribe": { + "message": "Resubscribe" + }, + "yourSubscriptionIsExpired": { + "message": "Your subscription is expired. Please resubscribe to continue using premium features." + }, + "yourSubscriptionIsCanceled": { + "message": "Your subscription is canceled. Please resubscribe to continue using premium features." + }, "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 {