From 7520de229c4cf16cbb7f26095c84da6b91f12e9e Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Wed, 10 Jun 2026 13:09:47 -0500 Subject: [PATCH] [PM-37228] feat: Add price increase callout to organization subscription page (#21153) * chore: ignore root .worktrees/ directory * [PM-37228] feat: Add price increase callout to organization subscription page Customers being migrated to current pricing need in-product visibility of the upcoming increase ahead of renewal. Renders an info callout, driven by the new scheduledPriceIncrease organization warning, showing the new per-seat monthly price and effective date to match the renewal email. * Fix nullable DatePipe result to satisfy typescript-strict * Show cents only for non-whole prices in price-increase callout --- .gitignore | 1 + .../organization-billing.module.ts | 2 + ...nization-subscription-cloud.component.html | 3 + .../warnings/components/index.ts | 1 + ...d-price-increase-warning.component.spec.ts | 127 ++++++++++++++++++ ...eduled-price-increase-warning.component.ts | 55 ++++++++ .../organization-warnings.service.spec.ts | 63 +++++++++ .../services/organization-warnings.service.ts | 6 + .../warnings/types/organization-warnings.ts | 26 ++++ apps/web/src/locales/en/messages.json | 31 +++++ 10 files changed, 315 insertions(+) create mode 100644 apps/web/src/app/billing/organizations/warnings/components/organization-scheduled-price-increase-warning.component.spec.ts create mode 100644 apps/web/src/app/billing/organizations/warnings/components/organization-scheduled-price-increase-warning.component.ts diff --git a/.gitignore b/.gitignore index 5c04bd7df74..00910f4e72c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ .DS_Store Thumbs.db .claude/worktrees/ +.worktrees/ # IDEs and editors .idea/ diff --git a/apps/web/src/app/billing/organizations/organization-billing.module.ts b/apps/web/src/app/billing/organizations/organization-billing.module.ts index c111ac10834..f9c665b8cec 100644 --- a/apps/web/src/app/billing/organizations/organization-billing.module.ts +++ b/apps/web/src/app/billing/organizations/organization-billing.module.ts @@ -22,6 +22,7 @@ import { SecretsManagerAdjustSubscriptionComponent } from "./sm-adjust-subscript import { SecretsManagerSubscribeStandaloneComponent } from "./sm-subscribe-standalone.component"; import { SubscriptionHiddenComponent } from "./subscription-hidden.component"; import { SubscriptionStatusComponent } from "./subscription-status.component"; +import { OrganizationScheduledPriceIncreaseWarningComponent } from "./warnings/components"; @NgModule({ imports: [ @@ -31,6 +32,7 @@ import { SubscriptionStatusComponent } from "./subscription-status.component"; OrganizationPlansComponent, HeaderModule, BannerModule, + OrganizationScheduledPriceIncreaseWarningComponent, ChurnMitigationOfferDialogComponent, ], declarations: [ diff --git a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html index c5ec663a1cd..a9128ddbc26 100644 --- a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html +++ b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html @@ -83,6 +83,9 @@ + + + { + let fixture: ComponentFixture; + let warningsService: MockProxy; + let i18nService: MockProxy; + + const organization = { id: "org-id-123" } as Organization; + + const setWarning = (warning: OrganizationScheduledPriceIncreaseWarning | null) => { + warningsService.getScheduledPriceIncreaseWarning$.mockReturnValue(of(warning)); + }; + + beforeEach(async () => { + warningsService = mock(); + i18nService = mock(); + + i18nService.t.mockImplementation((key: string, ...args: any[]) => { + switch (key) { + case "scheduledPriceIncreaseWarningMonthly": + return `Your subscription price will increase to ${args[0]} per seat per month on ${args[1]}. Your plan features and any existing discounts will stay exactly the same.`; + case "scheduledPriceIncreaseWarningAnnually": + return `Your subscription price will increase to ${args[0]} per seat per month (billed annually) on ${args[1]}. Your plan features and any existing discounts will stay exactly the same.`; + case "priceIncreaseNotice": + return "Price increase notice"; + default: + return key; + } + }); + + await TestBed.configureTestingModule({ + imports: [OrganizationScheduledPriceIncreaseWarningComponent], + providers: [ + { provide: OrganizationWarningsService, useValue: warningsService }, + { provide: I18nService, useValue: i18nService }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(OrganizationScheduledPriceIncreaseWarningComponent); + fixture.componentRef.setInput("organization", organization); + }); + + it("does not render a callout when there is no warning", () => { + setWarning(null); + + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector("bit-callout")).toBeNull(); + }); + + it("renders the monthly message without the billed-annually fragment", () => { + setWarning({ + seatPrice: 6, + effectiveDate: new Date("2026-07-15T02:00:00Z"), + cadence: "monthly", + }); + + fixture.detectChanges(); + + expect(i18nService.t).toHaveBeenCalledWith( + "scheduledPriceIncreaseWarningMonthly", + "$6", + "July 15, 2026", + ); + const text = fixture.nativeElement.textContent as string; + expect(text).toContain("$6 per seat per month on July 15, 2026"); + expect(text).not.toContain("billed annually"); + }); + + it("renders the annual message including the billed-annually fragment", () => { + setWarning({ + seatPrice: 6, + effectiveDate: new Date("2026-07-15T02:00:00Z"), + cadence: "annually", + }); + + fixture.detectChanges(); + + expect(i18nService.t).toHaveBeenCalledWith( + "scheduledPriceIncreaseWarningAnnually", + "$6", + "July 15, 2026", + ); + expect(fixture.nativeElement.textContent).toContain("(billed annually)"); + }); + + it("shows cents only when the price has a fractional amount", () => { + setWarning({ + seatPrice: 6.5, + effectiveDate: new Date("2026-07-15T02:00:00Z"), + cadence: "monthly", + }); + + fixture.detectChanges(); + + expect(i18nService.t).toHaveBeenCalledWith( + "scheduledPriceIncreaseWarningMonthly", + "$6.50", + "July 15, 2026", + ); + }); + + it("renders an info callout with no title", () => { + setWarning({ + seatPrice: 6, + effectiveDate: new Date("2026-07-15T02:00:00Z"), + cadence: "monthly", + }); + + fixture.detectChanges(); + + const callout = fixture.debugElement.query(By.css("bit-callout")); + expect(callout.componentInstance.type()).toBe("info"); + expect(callout.componentInstance.title()).toBeNull(); + }); +}); diff --git a/apps/web/src/app/billing/organizations/warnings/components/organization-scheduled-price-increase-warning.component.ts b/apps/web/src/app/billing/organizations/warnings/components/organization-scheduled-price-increase-warning.component.ts new file mode 100644 index 00000000000..a63ee77bd5d --- /dev/null +++ b/apps/web/src/app/billing/organizations/warnings/components/organization-scheduled-price-increase-warning.component.ts @@ -0,0 +1,55 @@ +import { CommonModule, CurrencyPipe, DatePipe } from "@angular/common"; +import { ChangeDetectionStrategy, Component, inject, input } from "@angular/core"; +import { toObservable } from "@angular/core/rxjs-interop"; +import { map, switchMap } from "rxjs"; + +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { CalloutModule } from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; + +import { OrganizationWarningsService } from "../services"; + +@Component({ + selector: "app-organization-scheduled-price-increase-warning", + template: ` + @let warning = message$ | async; + + @if (warning) { + + {{ warning }} + + } + `, + imports: [CommonModule, CalloutModule, I18nPipe], + providers: [CurrencyPipe, DatePipe], + changeDetection: ChangeDetectionStrategy.OnPush, + host: { class: "tw-block tw-mt-6 tw-mb-8" }, +}) +export class OrganizationScheduledPriceIncreaseWarningComponent { + readonly organization = input.required(); + + private readonly organizationWarningsService = inject(OrganizationWarningsService); + private readonly i18nService = inject(I18nService); + private readonly currencyPipe = inject(CurrencyPipe); + private readonly datePipe = inject(DatePipe); + + protected readonly message$ = toObservable(this.organization).pipe( + switchMap((organization) => + this.organizationWarningsService.getScheduledPriceIncreaseWarning$(organization), + ), + map((warning) => { + if (!warning) { + return null; + } + const digitsInfo = Number.isInteger(warning.seatPrice) ? "1.0-0" : "1.2-2"; + const price = + this.currencyPipe.transform(warning.seatPrice, "$", "symbol", digitsInfo) ?? + `$${warning.seatPrice}`; + const date = this.datePipe.transform(warning.effectiveDate, "longDate", "UTC") ?? ""; + return warning.cadence === "annually" + ? this.i18nService.t("scheduledPriceIncreaseWarningAnnually", price, date) + : this.i18nService.t("scheduledPriceIncreaseWarningMonthly", price, date); + }), + ); +} diff --git a/apps/web/src/app/billing/organizations/warnings/services/organization-warnings.service.spec.ts b/apps/web/src/app/billing/organizations/warnings/services/organization-warnings.service.spec.ts index 1d066a7909c..67bb82927b8 100644 --- a/apps/web/src/app/billing/organizations/warnings/services/organization-warnings.service.spec.ts +++ b/apps/web/src/app/billing/organizations/warnings/services/organization-warnings.service.spec.ts @@ -304,6 +304,69 @@ describe("OrganizationWarningsService", () => { }); }); + describe("getScheduledPriceIncreaseWarning$", () => { + it("should return null when no scheduled price increase warning exists", (done) => { + organizationBillingClient.getWarnings.mockResolvedValue({} as OrganizationWarningsResponse); + + service.getScheduledPriceIncreaseWarning$(organization).subscribe((result) => { + expect(result).toBeNull(); + done(); + }); + }); + + it("should return null when platform is self-hosted", (done) => { + platformUtilsService.isSelfHost.mockReturnValue(true); + + service.getScheduledPriceIncreaseWarning$(organization).subscribe((result) => { + expect(result).toBeNull(); + expect(organizationBillingClient.getWarnings).not.toHaveBeenCalled(); + done(); + }); + }); + + it("should return the warning view model when a monthly price increase is scheduled", (done) => { + const effectiveDate = new Date("2026-07-15T02:00:00Z"); + const warning = { + seatPrice: 6, + effectiveDate, + cadence: "monthly" as const, + }; + organizationBillingClient.getWarnings.mockResolvedValue({ + scheduledPriceIncrease: warning, + } as OrganizationWarningsResponse); + + service.getScheduledPriceIncreaseWarning$(organization).subscribe((result) => { + expect(result).toEqual({ + seatPrice: 6, + effectiveDate, + cadence: "monthly", + }); + done(); + }); + }); + + it("should return the warning view model when an annual price increase is scheduled", (done) => { + const effectiveDate = new Date("2026-07-15T02:00:00Z"); + const warning = { + seatPrice: 6, + effectiveDate, + cadence: "annually" as const, + }; + organizationBillingClient.getWarnings.mockResolvedValue({ + scheduledPriceIncrease: warning, + } as OrganizationWarningsResponse); + + service.getScheduledPriceIncreaseWarning$(organization).subscribe((result) => { + expect(result).toEqual({ + seatPrice: 6, + effectiveDate, + cadence: "annually", + }); + done(); + }); + }); + }); + describe("getTaxIdWarning$", () => { it("should return null when no tax ID warning exists", (done) => { organizationBillingClient.getWarnings.mockResolvedValue({} as OrganizationWarningsResponse); diff --git a/apps/web/src/app/billing/organizations/warnings/services/organization-warnings.service.ts b/apps/web/src/app/billing/organizations/warnings/services/organization-warnings.service.ts index 1128eecf6f1..a0edf60943f 100644 --- a/apps/web/src/app/billing/organizations/warnings/services/organization-warnings.service.ts +++ b/apps/web/src/app/billing/organizations/warnings/services/organization-warnings.service.ts @@ -32,6 +32,7 @@ import { openChangePlanDialog } from "../../change-plan-dialog.component"; import { OrganizationFreeTrialWarning, OrganizationResellerRenewalWarning, + OrganizationScheduledPriceIncreaseWarning, OrganizationWarningsResponse, } from "../types"; @@ -144,6 +145,11 @@ export class OrganizationWarningsService { }), ); + getScheduledPriceIncreaseWarning$ = ( + organization: Organization, + ): Observable => + this.getWarning$(organization, (response) => response.scheduledPriceIncrease); + getTaxIdWarning$ = (organization: Organization): Observable => merge( this.getWarning$(organization, (response) => response.taxId), diff --git a/apps/web/src/app/billing/organizations/warnings/types/organization-warnings.ts b/apps/web/src/app/billing/organizations/warnings/types/organization-warnings.ts index 0c0097d5b09..753ba4e217e 100644 --- a/apps/web/src/app/billing/organizations/warnings/types/organization-warnings.ts +++ b/apps/web/src/app/billing/organizations/warnings/types/organization-warnings.ts @@ -12,10 +12,17 @@ export type OrganizationResellerRenewalWarning = { message: string; }; +export type OrganizationScheduledPriceIncreaseWarning = { + seatPrice: number; + effectiveDate: Date; + cadence: "monthly" | "annually"; +}; + export class OrganizationWarningsResponse extends BaseResponse { freeTrial?: FreeTrialWarningResponse; inactiveSubscription?: InactiveSubscriptionWarningResponse; resellerRenewal?: ResellerRenewalWarningResponse; + scheduledPriceIncrease?: ScheduledPriceIncreaseWarningResponse; taxId?: TaxIdWarningResponse; constructor(response: any) { @@ -34,6 +41,12 @@ export class OrganizationWarningsResponse extends BaseResponse { if (resellerWarning) { this.resellerRenewal = new ResellerRenewalWarningResponse(resellerWarning); } + const scheduledPriceIncreaseWarning = this.getResponseProperty("ScheduledPriceIncrease"); + if (scheduledPriceIncreaseWarning) { + this.scheduledPriceIncrease = new ScheduledPriceIncreaseWarningResponse( + scheduledPriceIncreaseWarning, + ); + } const taxIdWarning = this.getResponseProperty("TaxId"); if (taxIdWarning) { this.taxId = new TaxIdWarningResponse(taxIdWarning); @@ -112,3 +125,16 @@ class PastDueRenewal extends BaseResponse { this.suspensionDate = new Date(this.getResponseProperty("SuspensionDate")); } } + +class ScheduledPriceIncreaseWarningResponse extends BaseResponse { + seatPrice: number; + effectiveDate: Date; + cadence: "monthly" | "annually"; + + constructor(response: any) { + super(response); + this.seatPrice = this.getResponseProperty("SeatPrice"); + this.effectiveDate = new Date(this.getResponseProperty("EffectiveDate")); + this.cadence = this.getResponseProperty("Cadence"); + } +} diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 22357131782..58c86463502 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -12788,6 +12788,37 @@ } } }, + "scheduledPriceIncreaseWarningMonthly": { + "message": "Your subscription price will increase to $PRICE$ per seat per month on $DATE$. Your plan features and any existing discounts will stay exactly the same.", + "placeholders": { + "price": { + "content": "$1", + "example": "$6.00" + }, + "date": { + "content": "$2", + "example": "July 15, 2026" + } + }, + "description": "Shown when the organization's monthly subscription price will increase on the next renewal date." + }, + "scheduledPriceIncreaseWarningAnnually": { + "message": "Your subscription price will increase to $PRICE$ per seat per month (billed annually) on $DATE$. Your plan features and any existing discounts will stay exactly the same.", + "placeholders": { + "price": { + "content": "$1", + "example": "$6.00" + }, + "date": { + "content": "$2", + "example": "July 15, 2026" + } + }, + "description": "Shown when the organization's annually billed subscription price will increase on the next renewal date." + }, + "priceIncreaseNotice": { + "message": "Price increase notice" + }, "restartOrganizationSubscription": { "message": "Organization subscription restarted" },