[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
This commit is contained in:
Alex Morask 2026-06-10 13:09:47 -05:00 committed by GitHub
parent d87c6aab4f
commit 7520de229c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 315 additions and 0 deletions

1
.gitignore vendored
View File

@ -2,6 +2,7 @@
.DS_Store
Thumbs.db
.claude/worktrees/
.worktrees/
# IDEs and editors
.idea/

View File

@ -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: [

View File

@ -83,6 +83,9 @@
</div>
</ng-container>
<app-organization-scheduled-price-increase-warning [organization]="userOrg">
</app-organization-scheduled-price-increase-warning>
<ng-container *ngIf="userOrg.canEditSubscription">
<div class="tw-mt-5">
<button

View File

@ -1,2 +1,3 @@
export * from "./organization-free-trial-warning.component";
export * from "./organization-reseller-renewal-warning.component";
export * from "./organization-scheduled-price-increase-warning.component";

View File

@ -0,0 +1,127 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { mock, MockProxy } from "jest-mock-extended";
import { of } from "rxjs";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { OrganizationWarningsService } from "../services";
import { OrganizationScheduledPriceIncreaseWarning } from "../types";
import { OrganizationScheduledPriceIncreaseWarningComponent } from "./organization-scheduled-price-increase-warning.component";
describe("OrganizationScheduledPriceIncreaseWarningComponent", () => {
let fixture: ComponentFixture<OrganizationScheduledPriceIncreaseWarningComponent>;
let warningsService: MockProxy<OrganizationWarningsService>;
let i18nService: MockProxy<I18nService>;
const organization = { id: "org-id-123" } as Organization;
const setWarning = (warning: OrganizationScheduledPriceIncreaseWarning | null) => {
warningsService.getScheduledPriceIncreaseWarning$.mockReturnValue(of(warning));
};
beforeEach(async () => {
warningsService = mock<OrganizationWarningsService>();
i18nService = mock<I18nService>();
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();
});
});

View File

@ -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) {
<bit-callout type="info" [title]="null" [accessibleName]="'priceIncreaseNotice' | i18n">
{{ warning }}
</bit-callout>
}
`,
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<Organization>();
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);
}),
);
}

View File

@ -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);

View File

@ -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<OrganizationScheduledPriceIncreaseWarning | null> =>
this.getWarning$(organization, (response) => response.scheduledPriceIncrease);
getTaxIdWarning$ = (organization: Organization): Observable<TaxIdWarningType | null> =>
merge(
this.getWarning$(organization, (response) => response.taxId),

View File

@ -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");
}
}

View File

@ -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"
},