Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";

import {
AnnualUpgradeOfferResponseModel,
ChurnMitigationOfferResponseModel,
OrganizationBillingClient,
} from "./organization-billing.client";
Expand Down Expand Up @@ -94,4 +95,67 @@ describe("OrganizationBillingClient", () => {
await expect(sut.redeemChurnOffer(orgId)).rejects.toBeInstanceOf(ErrorResponse);
});
});

describe("getAnnualUpgradeOffer", () => {
it("calls the correct route and deserializes the response", async () => {
const raw = {
CurrentAnnualCost: 60,
NewAnnualCost: 48,
Savings: 12,
};
mockApiService.send.mockResolvedValue(raw);

const result = await sut.getAnnualUpgradeOffer(orgId);

expect(mockApiService.send).toHaveBeenCalledWith(
"GET",
`/organizations/${orgId}/billing/vnext/annual-upgrade-offer`,
null,
true,
true,
);
expect(result).toBeInstanceOf(AnnualUpgradeOfferResponseModel);
expect(result!.currentAnnualCost).toBe(60);
expect(result!.newAnnualCost).toBe(48);
expect(result!.savings).toBe(12);
});

it("returns null when the API returns an empty body (ineligible org)", async () => {
mockApiService.send.mockResolvedValue(null);

const result = await sut.getAnnualUpgradeOffer(orgId);

expect(result).toBeNull();
});

it("propagates errors from the API", async () => {
const serverError = new ErrorResponse(null, 500);
mockApiService.send.mockRejectedValue(serverError);

await expect(sut.getAnnualUpgradeOffer(orgId)).rejects.toBeInstanceOf(ErrorResponse);
});
});

describe("redeemAnnualUpgradeOffer", () => {
it("calls the correct route", async () => {
mockApiService.send.mockResolvedValue(undefined);

await sut.redeemAnnualUpgradeOffer(orgId);

expect(mockApiService.send).toHaveBeenCalledWith(
"POST",
`/organizations/${orgId}/billing/vnext/annual-upgrade-offer/redeem`,
null,
true,
false,
);
});

it("propagates errors from the API", async () => {
const serverError = new ErrorResponse(null, 422);
mockApiService.send.mockRejectedValue(serverError);

await expect(sut.redeemAnnualUpgradeOffer(orgId)).rejects.toBeInstanceOf(ErrorResponse);
});
});
});
36 changes: 36 additions & 0 deletions apps/web/src/app/billing/clients/organization-billing.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,19 @@ export class ChurnMitigationOfferResponseModel extends BaseResponse {
}
}

export class AnnualUpgradeOfferResponseModel extends BaseResponse {
currentAnnualCost: number;
newAnnualCost: number;
savings: number;

constructor(response: any) {
super(response);
this.currentAnnualCost = this.getResponseProperty("CurrentAnnualCost");
this.newAnnualCost = this.getResponseProperty("NewAnnualCost");
this.savings = this.getResponseProperty("Savings");
}
}

@Injectable({ providedIn: "root" })
export class OrganizationBillingClient {
constructor(private apiService: ApiService) {}
Expand Down Expand Up @@ -64,4 +77,27 @@ export class OrganizationBillingClient {
false,
);
};

getAnnualUpgradeOffer = async (
organizationId: OrganizationId,
): Promise<AnnualUpgradeOfferResponseModel | null> => {
const response = await this.apiService.send(
"GET",
`/organizations/${organizationId}/billing/vnext/annual-upgrade-offer`,
null,
true,
true,
);
return response ? new AnnualUpgradeOfferResponseModel(response) : null;
};

redeemAnnualUpgradeOffer = async (organizationId: OrganizationId): Promise<void> => {
await this.apiService.send(
"POST",
`/organizations/${organizationId}/billing/vnext/annual-upgrade-offer/redeem`,
null,
true,
false,
);
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@
(reinstatementRequested)="reinstate()"
*ngIf="!userOrg.isFreeOrg"
></app-subscription-status>
<bit-callout type="info" *ngIf="sub.pendingAnnualUpgrade">
{{
"pendingAnnualUpgradeNotice"
| i18n: (sub.pendingAnnualUpgrade.effectiveDate | date: "mediumDate")
}}
</bit-callout>
<ng-container *ngIf="userOrg.canEditSubscription">
<div class="tw-flex-col">
<strong
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,135 @@ describe("OrganizationSubscriptionCloudComponent.cancelSubscription", () => {
});
});

describe("OrganizationSubscriptionCloudComponent.load (pendingAnnualUpgrade line items)", () => {
const mockApiService = mock<ApiService>();
const mockI18nService = mock<I18nService>();
const mockLogService = mock<LogService>();
const mockOrganizationService = mock<OrganizationService>();
const mockAccountService = mock<AccountService>();
const mockOrganizationApiService = mock<OrganizationApiServiceAbstraction>();
const mockDialogService = mock<DialogService>();
const mockToastService = mock<ToastService>();
const mockOrganizationUserApiService = mock<OrganizationUserApiService>();
const mockDatePipe = mock<DatePipe>();
const mockOrganizationBillingClient = mock<OrganizationBillingClient>();

let component: OrganizationSubscriptionCloudComponent;

const mockUserOrg = {
hasProvider: false,
isOwner: true,
hasReseller: false,
isProviderUser: false,
hasBillableProvider: false,
productTierType: 1,
} as any;

beforeEach(() => {
TestBed.configureTestingModule({
providers: [
OrganizationSubscriptionCloudComponent,
{ provide: ApiService, useValue: mockApiService },
{ provide: I18nService, useValue: mockI18nService },
{ provide: LogService, useValue: mockLogService },
{ provide: OrganizationService, useValue: mockOrganizationService },
{ provide: AccountService, useValue: mockAccountService },
{ provide: OrganizationApiServiceAbstraction, useValue: mockOrganizationApiService },
{
provide: ActivatedRoute,
useValue: {
snapshot: {
params: {},
queryParams: {},
queryParamMap: { get: (_key: string): string | null => null },
},
},
},
{ provide: DialogService, useValue: mockDialogService },
{ provide: ToastService, useValue: mockToastService },
{ provide: OrganizationUserApiService, useValue: mockOrganizationUserApiService },
{ provide: DatePipe, useValue: mockDatePipe },
{ provide: OrganizationBillingClient, useValue: mockOrganizationBillingClient },
],
});

component = TestBed.inject(OrganizationSubscriptionCloudComponent);

mockAccountService.activeAccount$ = of({ id: "user-1" } as any);
mockOrganizationService.organizations$ = jest.fn().mockReturnValue(of([mockUserOrg])) as any;
mockI18nService.locale$ = of("en");
mockOrganizationApiService.getApiKeyInformation.mockResolvedValue({ data: [] } as any);
});

it("sources line items from pendingAnnualUpgrade when present", async () => {
const pendingItems = [
{
name: "Teams (Annually) Seat",
amount: 48,
quantity: 5,
interval: "year",
productName: null,
} as any,
];
mockOrganizationApiService.getSubscription.mockResolvedValue({
subscription: {
items: [{ name: "Teams (Monthly) Seat", amount: 4, quantity: 5, interval: "month" }],
},
plan: { name: "Teams (Monthly)", SecretsManager: { seatPrice: 0 } },
pendingAnnualUpgrade: {
plan: { name: "Teams (Annually)" },
lineItems: pendingItems,
effectiveDate: new Date(),
},
} as any);

await component.load();

expect(component.lineItems[0].interval).toBe("year");
expect(component.lineItems[0].name).toBe("Teams (Annually) Seat");
});

it("classifies pending line items against the pending plan's SecretsManager seat price", () => {
const pendingItems = [
{ name: "Secrets Manager Seat", amount: 6, quantity: 3, interval: "year" } as any,
{ name: "Members", amount: 48, quantity: 3, interval: "year" } as any,
];
mockOrganizationApiService.getSubscription.mockResolvedValue({
subscription: {
items: [{ name: "Teams (Monthly) Seat", amount: 4, quantity: 5, interval: "month" }],
},
plan: { name: "Teams (Monthly)", SecretsManager: { seatPrice: 0 } },
pendingAnnualUpgrade: {
plan: { name: "Teams (Annually)", SecretsManager: { seatPrice: 6 } },
lineItems: pendingItems,
effectiveDate: new Date(),
},
} as any);

return component.load().then(() => {
const smItem = component.lineItems.find((i: any) => i.name === "Secrets Manager Seat");
const pmItem = component.lineItems.find((i: any) => i.name === "Members");

expect(smItem.productName).toBe("secretsManager");
expect(pmItem.productName).toBe("passwordManager");
});
});

it("falls back to the current subscription items when there is no pendingAnnualUpgrade", async () => {
mockOrganizationApiService.getSubscription.mockResolvedValue({
subscription: {
items: [{ name: "Teams (Monthly) Seat", amount: 4, quantity: 5, interval: "month" }],
},
plan: { name: "Teams (Monthly)", SecretsManager: { seatPrice: 0 } },
} as any);

await component.load();

expect(component.lineItems[0].interval).toBe("month");
expect(component.lineItems[0].name).toBe("Teams (Monthly) Seat");
});
});

describe("OrganizationSubscriptionCloudComponent.discountPrice", () => {
const mockApiService = mock<ApiService>();
const mockI18nService = mock<I18nService>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -168,13 +168,18 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy

if (this.showSubscription) {
this.sub = await this.organizationApiService.getSubscription(this.organizationId);
this.lineItems = this.sub?.subscription?.items;

// When an annual upgrade is scheduled but not yet active, show the scheduled (annual) line
// items instead of the still-active monthly ones. Classify Password Manager vs. Secrets
// Manager against the scheduled plan's seat price so the label matches the shown items.
const effectivePlan = this.sub?.pendingAnnualUpgrade?.plan ?? this.sub?.plan;
this.lineItems = this.sub?.pendingAnnualUpgrade?.lineItems ?? this.sub?.subscription?.items;

if (this.lineItems && this.lineItems.length) {
this.lineItems = this.lineItems
.map((item) => {
const itemTotalAmount = item.amount * item.quantity;
const seatPriceTotal = this.sub.plan?.SecretsManager?.seatPrice * item.quantity;
const seatPriceTotal = effectivePlan?.SecretsManager?.seatPrice * item.quantity;
item.productName =
itemTotalAmount === seatPriceTotal || item.name.includes("Service Accounts")
? "secretsManager"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,4 +111,25 @@ describe("SubscriptionStatusComponent", () => {
expect(component.status).toBe("incomplete_expired");
});
});

describe("planName getter", () => {
it("uses the response plan name when there is no pending annual upgrade", () => {
component.organizationSubscriptionResponse = {
...makeOrgResponse(makeSubscription()),
plan: { name: "Teams (Monthly)" },
} as any;

expect(component.planName).toBe("Teams (Monthly)");
});

it("uses the pending annual upgrade plan name when present", () => {
component.organizationSubscriptionResponse = {
...makeOrgResponse(makeSubscription()),
plan: { name: "Teams (Monthly)" },
pendingAnnualUpgrade: { plan: { name: "Teams (Annually)" } },
} as any;

expect(component.planName).toBe("Teams (Annually)");
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,10 @@ export class SubscriptionStatusComponent {
}

get planName() {
return this.organizationSubscriptionResponse.plan.name;
return (
this.organizationSubscriptionResponse.pendingAnnualUpgrade?.plan?.name ??
this.organizationSubscriptionResponse.plan.name
);
}

get status(): string {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,45 @@
</bit-radio-button>
}
</bit-radio-group>
@if (isBusiness && annualUpgradeOffer && formGroup.value.reason === annualOfferReasonValue) {
<div data-testid="annual-upgrade-offer" class="tw-mt-3">
<bit-callout
type="info"
[icon]="null"
[title]="'switchToAnnualAndSave' | i18n: formatCurrency(annualUpgradeOffer.savings)"
>
<div>
<div class="tw-flex tw-mb-1">
<span class="tw-text-muted tw-min-w-52">{{
"currentCostMonthlyBilling" | i18n
}}</span>
<span class="tw-font-medium">{{
formatCurrency(annualUpgradeOffer.currentAnnualCost)
}}</span>
</div>
<div class="tw-flex">
<span class="tw-text-muted tw-min-w-52">{{ "annualBillingCost" | i18n }}</span>
<span class="tw-font-medium">{{
formatCurrency(annualUpgradeOffer.newAnnualCost)
}}</span>
</div>
</div>
<p class="tw-text-muted tw-text-xs tw-mt-2">{{ "annualSwitchDisclaimer" | i18n }}</p>
@if (annualUpgradeRedeemError) {
<p class="tw-text-danger">{{ annualUpgradeRedeemError }}</p>
}
<button
type="button"
bitButton
buttonType="primary"
[bitAction]="switchToAnnualBilling"
[loading]="annualUpgradeRedeemLoading"
>
{{ "switchToAnnualBilling" | i18n }}
</button>
</bit-callout>
</div>
}
} @else {
<bit-form-field>
<bit-label>
Expand Down
Loading
Loading