mirror of
https://github.com/bitwarden/clients.git
synced 2026-07-21 21:17:06 +08:00
[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:
parent
d87c6aab4f
commit
7520de229c
1
.gitignore
vendored
1
.gitignore
vendored
@ -2,6 +2,7 @@
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.claude/worktrees/
|
||||
.worktrees/
|
||||
|
||||
# IDEs and editors
|
||||
.idea/
|
||||
|
||||
@ -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: [
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
}),
|
||||
);
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user