1
0
mirror of https://github.com/bitwarden/browser synced 2026-03-01 02:51:24 +00:00

feat(billing): add premium org component

This commit is contained in:
Stephon Brown
2026-01-23 14:21:58 -06:00
parent 021e4b4f0f
commit 0ec12df828
5 changed files with 1104 additions and 0 deletions

View File

@@ -0,0 +1,62 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog dialogSize="large" [loading]="loading()">
<span bitDialogTitle class="tw-font-medium">{{ upgradeToMessage() }}</span>
<ng-container bitDialogContent>
<section>
<div class="tw-pb-4">
<bit-form-field class="!tw-mb-0">
<bit-label>{{ "organizationName" | i18n }}</bit-label>
<input bitInput type="text" formControlName="organizationName" required />
</bit-form-field>
<p bitTypography="helper" class="tw-text-muted tw-pt-1 tw-pl-1">
{{ "organizationNameDescription" | i18n }}
</p>
</div>
<div class="tw-pb-8 !tw-mx-0">
<h5 bitTypography="h5">{{ "paymentMethod" | i18n }}</h5>
<app-enter-payment-method
[showBankAccount]="true"
[showAccountCredit]="false"
[group]="formGroup.controls.paymentForm"
[includeBillingAddress]="false"
#paymentComponent
></app-enter-payment-method>
<h5 bitTypography="h5" class="tw-pt-4 tw-pb-2">{{ "billingAddress" | i18n }}</h5>
<app-enter-billing-address
[group]="formGroup.controls.billingAddress"
[scenario]="{ type: 'checkout', supportsTaxId: false }"
>
</app-enter-billing-address>
</div>
</section>
<section>
<billing-cart-summary #cartSummaryComponent [cart]="cart()"></billing-cart-summary>
<p bitTypography="helper" class="tw-italic tw-text-muted !tw-mb-0">
{{ "paymentChargedWithTrial" | i18n }}
</p>
</section>
</ng-container>
<ng-container bitDialogFooter>
<button
bitButton
bitFormButton
buttonType="primary"
[disabled]="loading() || !isFormValid()"
type="submit"
>
{{ "upgrade" | i18n }}
</button>
<button
bitButton
type="button"
buttonType="secondary"
(click)="goBack.emit()"
[disabled]="loading()"
>
{{ "back" | i18n }}
</button>
</ng-container>
</bit-dialog>
</form>

View File

@@ -0,0 +1,409 @@
import {
Component,
input,
ChangeDetectionStrategy,
CUSTOM_ELEMENTS_SCHEMA,
signal,
} from "@angular/core";
import { ComponentFixture, TestBed, fakeAsync, tick } from "@angular/core/testing";
import { FormControl, FormGroup, Validators } from "@angular/forms";
import { mock } from "jest-mock-extended";
import { of } from "rxjs";
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction";
import {
BusinessSubscriptionPricingTier,
BusinessSubscriptionPricingTierId,
PersonalSubscriptionPricingTier,
PersonalSubscriptionPricingTierId,
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SyncService } from "@bitwarden/common/platform/sync";
import { ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { LogService } from "@bitwarden/logging";
import { CartSummaryComponent } from "@bitwarden/pricing";
import { AccountBillingClient } from "../../../clients/account-billing.client";
import { PreviewInvoiceClient } from "../../../clients/preview-invoice.client";
import {
EnterBillingAddressComponent,
EnterPaymentMethodComponent,
} from "../../../payment/components";
import {
PremiumOrgUpgradePaymentComponent,
PremiumOrgUpgradePaymentStatus,
} from "./premium-org-upgrade-payment.component";
import { PremiumOrgUpgradeService } from "./services/premium-org-upgrade.service";
// Mock Components
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: "billing-cart-summary",
template: `<h1>Mock Cart Summary</h1>`,
providers: [{ provide: CartSummaryComponent, useClass: MockCartSummaryComponent }],
})
class MockCartSummaryComponent {
readonly cart = input.required<any>();
readonly header = input<any>();
readonly isExpanded = signal(false);
}
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: "app-enter-payment-method",
template: `<h1>Mock Enter Payment Method</h1>`,
providers: [
{
provide: EnterPaymentMethodComponent,
useClass: MockEnterPaymentMethodComponent,
},
],
})
class MockEnterPaymentMethodComponent {
readonly group = input.required<any>();
readonly showBankAccount = input(true);
readonly showPayPal = input(true);
readonly showAccountCredit = input(false);
readonly hasEnoughAccountCredit = input(true);
readonly includeBillingAddress = input(false);
tokenize = jest.fn().mockResolvedValue({ type: "card", token: "mock-token" });
validate = jest.fn().mockReturnValue(true);
static getFormGroup = () =>
new FormGroup({
type: new FormControl<string>("card", { nonNullable: true }),
bankAccount: new FormGroup({
routingNumber: new FormControl<string>("", { nonNullable: true }),
accountNumber: new FormControl<string>("", { nonNullable: true }),
accountHolderName: new FormControl<string>("", { nonNullable: true }),
accountHolderType: new FormControl<string>("", { nonNullable: true }),
}),
billingAddress: new FormGroup({
country: new FormControl<string>("", { nonNullable: true }),
postalCode: new FormControl<string>("", { nonNullable: true }),
}),
});
}
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: "app-enter-billing-address",
template: `<h1>Mock Enter Billing Address</h1>`,
providers: [
{
provide: EnterBillingAddressComponent,
useClass: MockEnterBillingAddressComponent,
},
],
})
class MockEnterBillingAddressComponent {
readonly scenario = input.required<any>();
readonly group = input.required<any>();
static getFormGroup = () =>
new FormGroup({
country: new FormControl<string>("", {
nonNullable: true,
validators: [Validators.required],
}),
postalCode: new FormControl<string>("", {
nonNullable: true,
validators: [Validators.required],
}),
line1: new FormControl<string | null>(null),
line2: new FormControl<string | null>(null),
city: new FormControl<string | null>(null),
state: new FormControl<string | null>(null),
taxId: new FormControl<string | null>(null),
});
}
describe("PremiumOrgUpgradePaymentComponent", () => {
beforeAll(() => {
// Mock IntersectionObserver - required because DialogComponent uses it to detect scrollable content.
// This browser API doesn't exist in the Jest/Node.js test environment.
// This is necessary because we are unable to mock DialogComponent which is not directly importable
global.IntersectionObserver = class IntersectionObserver {
constructor() {}
disconnect() {}
observe() {}
takeRecords(): IntersectionObserverEntry[] {
return [];
}
unobserve() {}
} as any;
});
let component: PremiumOrgUpgradePaymentComponent;
let fixture: ComponentFixture<PremiumOrgUpgradePaymentComponent>;
const mockPremiumOrgUpgradeService = mock<PremiumOrgUpgradeService>();
const mockSubscriptionPricingService = mock<SubscriptionPricingServiceAbstraction>();
const mockToastService = mock<ToastService>();
const mockAccountBillingClient = mock<AccountBillingClient>();
const mockPreviewInvoiceClient = mock<PreviewInvoiceClient>();
const mockLogService = mock<LogService>();
const mockAccount = { id: "user-id", email: "test@bitwarden.com" } as Account;
const mockTeamsPlan: BusinessSubscriptionPricingTier = {
id: "teams",
name: "Teams",
description: "Teams plan",
availableCadences: ["annually"],
passwordManager: {
annualPricePerUser: 48,
type: "scalable",
features: [],
},
secretsManager: {
annualPricePerUser: 24,
type: "scalable",
features: [],
},
};
const mockFamiliesPlan: PersonalSubscriptionPricingTier = {
id: "families",
name: "Families",
description: "Families plan",
availableCadences: ["annually"],
passwordManager: {
annualPrice: 40,
users: 6,
type: "packaged",
features: [],
},
};
beforeEach(async () => {
jest.clearAllMocks();
mockAccountBillingClient.upgradePremiumToOrganization.mockResolvedValue(undefined);
mockPremiumOrgUpgradeService.upgradeToOrganization.mockResolvedValue(undefined);
mockSubscriptionPricingService.getBusinessSubscriptionPricingTiers$.mockReturnValue(
of([mockTeamsPlan]),
);
mockSubscriptionPricingService.getPersonalSubscriptionPricingTiers$.mockReturnValue(
of([mockFamiliesPlan]),
);
await TestBed.configureTestingModule({
imports: [PremiumOrgUpgradePaymentComponent],
providers: [
{ provide: PremiumOrgUpgradeService, useValue: mockPremiumOrgUpgradeService },
{
provide: SubscriptionPricingServiceAbstraction,
useValue: mockSubscriptionPricingService,
},
{ provide: ToastService, useValue: mockToastService },
{ provide: LogService, useValue: mockLogService },
{ provide: I18nService, useValue: { t: (key: string) => key } },
{ provide: AccountBillingClient, useValue: mockAccountBillingClient },
{ provide: PreviewInvoiceClient, useValue: mockPreviewInvoiceClient },
{
provide: KeyService,
useValue: {
makeOrgKey: jest.fn().mockResolvedValue(["encrypted-key", "decrypted-key"]),
},
},
{
provide: SyncService,
useValue: { fullSync: jest.fn().mockResolvedValue(undefined) },
},
],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
.overrideComponent(PremiumOrgUpgradePaymentComponent, {
add: {
imports: [
MockEnterBillingAddressComponent,
MockEnterPaymentMethodComponent,
MockCartSummaryComponent,
],
},
remove: {
imports: [
EnterBillingAddressComponent,
EnterPaymentMethodComponent,
CartSummaryComponent,
],
},
})
.compileComponents();
fixture = TestBed.createComponent(PremiumOrgUpgradePaymentComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput("selectedPlanId", "teams" as BusinessSubscriptionPricingTierId);
fixture.componentRef.setInput("account", mockAccount);
fixture.detectChanges();
// Wait for ngOnInit to complete
await fixture.whenStable();
});
it("should create", () => {
expect(component).toBeTruthy();
});
it("should initialize with the correct plan details", () => {
expect(component["selectedPlan"]()).not.toBeNull();
expect(component["selectedPlan"]()?.details.id).toBe("teams");
expect(component["upgradeToMessage"]()).toContain("startFreeTrial");
});
it("should handle invalid plan id that doesn't exist in pricing tiers", async () => {
// Create a fresh component with an invalid plan ID from the start
const newFixture = TestBed.createComponent(PremiumOrgUpgradePaymentComponent);
const newComponent = newFixture.componentInstance;
newFixture.componentRef.setInput(
"selectedPlanId",
"non-existent-plan" as BusinessSubscriptionPricingTierId,
);
newFixture.componentRef.setInput("account", mockAccount);
newFixture.detectChanges();
await newFixture.whenStable();
expect(newComponent["selectedPlan"]()).toBeNull();
});
it("should handle invoice preview errors gracefully", fakeAsync(() => {
mockPremiumOrgUpgradeService.previewProratedInvoice.mockRejectedValue(
new Error("Network error"),
);
// Component should still render and be usable even when invoice preview fails
fixture = TestBed.createComponent(PremiumOrgUpgradePaymentComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput("selectedPlanId", "teams" as BusinessSubscriptionPricingTierId);
fixture.componentRef.setInput("account", mockAccount);
fixture.detectChanges();
tick();
expect(component).toBeTruthy();
expect(component["selectedPlan"]()).not.toBeNull();
expect(mockToastService.showToast).not.toHaveBeenCalled();
}));
describe("submit", () => {
it("should successfully upgrade to organization", async () => {
const completeSpy = jest.spyOn(component["complete"], "emit");
// Mock isFormValid and processUpgrade to bypass form validation
jest.spyOn(component as any, "isFormValid").mockReturnValue(true);
jest.spyOn(component as any, "processUpgrade").mockResolvedValue({
status: PremiumOrgUpgradePaymentStatus.UpgradedToTeams,
organizationId: null,
});
component["formGroup"].setValue({
organizationName: "My New Org",
paymentForm: {
type: "card",
bankAccount: {
routingNumber: "",
accountNumber: "",
accountHolderName: "",
accountHolderType: "",
},
billingAddress: {
country: "",
postalCode: "",
},
},
billingAddress: {
country: "US",
postalCode: "90210",
line1: "123 Main St",
line2: "",
city: "Beverly Hills",
state: "CA",
taxId: "",
},
});
await component["submit"]();
expect(mockToastService.showToast).toHaveBeenCalledWith({
variant: "success",
message: "organizationUpdated",
});
expect(completeSpy).toHaveBeenCalledWith({
status: PremiumOrgUpgradePaymentStatus.UpgradedToTeams,
organizationId: null,
});
});
it("should show an error toast if upgrade fails", async () => {
// Mock isFormValid to return true
jest.spyOn(component as any, "isFormValid").mockReturnValue(true);
// Mock processUpgrade to throw an error
jest
.spyOn(component as any, "processUpgrade")
.mockRejectedValue(new Error("Submission Error"));
component["formGroup"].setValue({
organizationName: "My New Org",
paymentForm: {
type: "card",
bankAccount: {
routingNumber: "",
accountNumber: "",
accountHolderName: "",
accountHolderType: "",
},
billingAddress: {
country: "",
postalCode: "",
},
},
billingAddress: {
country: "US",
postalCode: "90210",
line1: "123 Main St",
line2: "",
city: "Beverly Hills",
state: "CA",
taxId: "",
},
});
await component["submit"]();
expect(mockToastService.showToast).toHaveBeenCalledWith({
variant: "error",
message: "upgradeErrorMessage",
});
});
it("should not submit if the form is invalid", async () => {
const markAllAsTouchedSpy = jest.spyOn(component["formGroup"], "markAllAsTouched");
component["formGroup"].get("organizationName")?.setValue("");
fixture.detectChanges();
await component["submit"]();
expect(markAllAsTouchedSpy).toHaveBeenCalled();
expect(mockPremiumOrgUpgradeService.upgradeToOrganization).not.toHaveBeenCalled();
});
});
it("should map plan id to correct upgrade status", () => {
expect(component["getUpgradeStatus"]("families" as PersonalSubscriptionPricingTierId)).toBe(
PremiumOrgUpgradePaymentStatus.UpgradedToFamilies,
);
expect(component["getUpgradeStatus"]("teams" as BusinessSubscriptionPricingTierId)).toBe(
PremiumOrgUpgradePaymentStatus.UpgradedToTeams,
);
expect(component["getUpgradeStatus"]("enterprise" as BusinessSubscriptionPricingTierId)).toBe(
PremiumOrgUpgradePaymentStatus.UpgradedToEnterprise,
);
expect(component["getUpgradeStatus"]("some-other-plan" as any)).toBe(
PremiumOrgUpgradePaymentStatus.Closed,
);
});
});

View File

@@ -0,0 +1,329 @@
import {
AfterViewInit,
ChangeDetectionStrategy,
Component,
computed,
DestroyRef,
input,
OnInit,
output,
signal,
viewChild,
} from "@angular/core";
import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop";
import { FormControl, FormGroup, Validators } from "@angular/forms";
import {
catchError,
of,
combineLatest,
startWith,
debounceTime,
switchMap,
Observable,
from,
defer,
} from "rxjs";
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction";
import {
BusinessSubscriptionPricingTier,
BusinessSubscriptionPricingTierId,
PersonalSubscriptionPricingTier,
PersonalSubscriptionPricingTierId,
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values";
import { ButtonModule, DialogModule, ToastService } from "@bitwarden/components";
import { LogService } from "@bitwarden/logging";
import { Cart, CartSummaryComponent } from "@bitwarden/pricing";
import { SharedModule } from "@bitwarden/web-vault/app/shared";
import {
EnterBillingAddressComponent,
EnterPaymentMethodComponent,
getBillingAddressFromForm,
} from "../../../payment/components";
import {
PremiumOrgUpgradeService,
PremiumOrgUpgradePlanDetails,
InvoicePreview,
} from "./services/premium-org-upgrade.service";
export const PremiumOrgUpgradePaymentStatus = {
Closed: "closed",
UpgradedToTeams: "upgradedToTeams",
UpgradedToEnterprise: "upgradedToEnterprise",
UpgradedToFamilies: "upgradedToFamilies",
} as const;
export type PremiumOrgUpgradePaymentStatus = UnionOfValues<typeof PremiumOrgUpgradePaymentStatus>;
export type PremiumOrgUpgradePaymentResult = {
status: PremiumOrgUpgradePaymentStatus;
organizationId?: string | null;
};
@Component({
selector: "app-premium-org-upgrade-payment",
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
DialogModule,
SharedModule,
CartSummaryComponent,
ButtonModule,
EnterPaymentMethodComponent,
EnterBillingAddressComponent,
],
providers: [PremiumOrgUpgradeService],
templateUrl: "./premium-org-upgrade-payment.component.html",
})
export class PremiumOrgUpgradePaymentComponent implements OnInit, AfterViewInit {
private readonly INITIAL_TAX_VALUE = 0;
private readonly DEFAULT_SEAT_COUNT = 1;
private readonly DEFAULT_CADENCE = "annually";
protected readonly selectedPlanId = input.required<
PersonalSubscriptionPricingTierId | BusinessSubscriptionPricingTierId
>();
protected readonly account = input.required<Account>();
protected goBack = output<void>();
protected complete = output<PremiumOrgUpgradePaymentResult>();
readonly paymentComponent = viewChild.required(EnterPaymentMethodComponent);
readonly cartSummaryComponent = viewChild.required(CartSummaryComponent);
protected formGroup = new FormGroup({
organizationName: new FormControl<string>("", [Validators.required]),
paymentForm: EnterPaymentMethodComponent.getFormGroup(),
billingAddress: EnterBillingAddressComponent.getFormGroup(),
});
protected readonly selectedPlan = signal<PremiumOrgUpgradePlanDetails | null>(null);
protected readonly loading = signal(true);
protected readonly upgradeToMessage = signal("");
// Use defer to lazily create the observable when subscribed to
protected estimatedInvoice$ = defer(() =>
this.formGroup.controls.billingAddress.valueChanges.pipe(
startWith(this.formGroup.controls.billingAddress.value),
debounceTime(1000),
switchMap(() => this.refreshInvoicePreview$()),
),
);
protected readonly estimatedInvoice = toSignal(this.estimatedInvoice$, {
initialValue: { tax: this.INITIAL_TAX_VALUE, total: 0, credit: 0 },
});
// Cart Summary data
protected readonly cart = computed<Cart>(() => {
if (!this.selectedPlan()) {
return {
passwordManager: {
seats: { translationKey: "", cost: 0, quantity: 0 },
},
cadence: this.DEFAULT_CADENCE,
estimatedTax: this.INITIAL_TAX_VALUE,
};
}
return {
passwordManager: {
seats: {
translationKey: this.selectedPlan()?.details.name ?? "",
cost: this.selectedPlan()?.cost ?? 0,
quantity: this.DEFAULT_SEAT_COUNT,
},
},
cadence: this.DEFAULT_CADENCE,
estimatedTax: this.estimatedInvoice().tax,
discount: { type: "amount-off", value: this.estimatedInvoice().credit },
};
});
constructor(
private i18nService: I18nService,
private subscriptionPricingService: SubscriptionPricingServiceAbstraction,
private toastService: ToastService,
private logService: LogService,
private destroyRef: DestroyRef,
private premiumOrgUpgradeService: PremiumOrgUpgradeService,
) {}
async ngOnInit(): Promise<void> {
combineLatest([
this.subscriptionPricingService.getPersonalSubscriptionPricingTiers$(),
this.subscriptionPricingService.getBusinessSubscriptionPricingTiers$(),
])
.pipe(
catchError((error: unknown) => {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("error"),
message: this.i18nService.t("unexpectedError"),
});
this.loading.set(false);
return of([]);
}),
takeUntilDestroyed(this.destroyRef),
)
.subscribe(([personalPlans, businessPlans]) => {
const plans: (PersonalSubscriptionPricingTier | BusinessSubscriptionPricingTier)[] = [
...personalPlans,
...businessPlans,
];
const planDetails = plans.find((plan) => plan.id === this.selectedPlanId());
if (planDetails) {
this.selectedPlan.set({
tier: this.selectedPlanId(),
details: planDetails,
cost: this.getPlanPrice(planDetails),
});
this.upgradeToMessage.set(this.i18nService.t("startFreeTrial", planDetails.name));
} else {
this.complete.emit({
status: PremiumOrgUpgradePaymentStatus.Closed,
organizationId: null,
});
return;
}
});
this.loading.set(false);
}
ngAfterViewInit(): void {
const cartSummaryComponent = this.cartSummaryComponent();
cartSummaryComponent.isExpanded.set(false);
}
protected submit = async (): Promise<void> => {
if (!this.isFormValid()) {
this.formGroup.markAllAsTouched();
return;
}
if (!this.selectedPlan()) {
throw new Error("No plan selected");
}
try {
const result = await this.processUpgrade();
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("organizationUpdated", this.selectedPlan()?.details.name),
});
this.complete.emit(result);
} catch (error: unknown) {
this.logService.error("Upgrade failed:", error);
this.toastService.showToast({
variant: "error",
message: this.i18nService.t("upgradeErrorMessage"),
});
}
};
protected isFormValid(): boolean {
return this.formGroup.valid && this.paymentComponent().validate();
}
private async processUpgrade(): Promise<PremiumOrgUpgradePaymentResult> {
const billingAddress = getBillingAddressFromForm(this.formGroup.controls.billingAddress);
const organizationName = this.formGroup.value?.organizationName;
if (!billingAddress.country || !billingAddress.postalCode) {
throw new Error("Billing address is incomplete");
}
if (!organizationName) {
throw new Error("Organization name is required");
}
const paymentMethod = await this.paymentComponent().tokenize();
if (!paymentMethod) {
throw new Error("Payment method is required");
}
await this.premiumOrgUpgradeService.upgradeToOrganization(
this.account(),
organizationName,
this.selectedPlan()!,
billingAddress,
);
return {
status: this.getUpgradeStatus(this.selectedPlanId()),
organizationId: null,
};
}
private getUpgradeStatus(planId: string): PremiumOrgUpgradePaymentStatus {
switch (planId) {
case "families":
return PremiumOrgUpgradePaymentStatus.UpgradedToFamilies;
case "teams":
return PremiumOrgUpgradePaymentStatus.UpgradedToTeams;
case "enterprise":
return PremiumOrgUpgradePaymentStatus.UpgradedToEnterprise;
default:
return PremiumOrgUpgradePaymentStatus.Closed;
}
}
/**
* Calculates the price for the currently selected plan.
*
* This method retrieves the `passwordManager` details from the selected plan. It then determines
* the appropriate price based on the properties available on the `passwordManager` object.
* It prioritizes `annualPrice` for individual-style plans and falls back to `annualPricePerUser`
* for user-based plans.
*
* @returns The annual price of the plan as a number. Returns `0` if the plan or its price cannot be determined.
*/
private getPlanPrice(
plan: PersonalSubscriptionPricingTier | BusinessSubscriptionPricingTier,
): number {
const passwordManager = plan.passwordManager;
if (!passwordManager) {
return 0;
}
if ("annualPrice" in passwordManager) {
return passwordManager.annualPrice ?? 0;
} else if ("annualPricePerUser" in passwordManager) {
return passwordManager.annualPricePerUser ?? 0;
}
return 0;
}
/**
* Refreshes the invoice preview based on the current form state.
*/
private refreshInvoicePreview$(): Observable<InvoicePreview> {
if (this.formGroup.invalid || !this.selectedPlan()) {
return of({ tax: this.INITIAL_TAX_VALUE, total: 0, credit: 0 });
}
const billingAddress = getBillingAddressFromForm(this.formGroup.controls.billingAddress);
if (!billingAddress.country || !billingAddress.postalCode) {
return of({ tax: this.INITIAL_TAX_VALUE, total: 0, credit: 0 });
}
return from(
this.premiumOrgUpgradeService.previewProratedInvoice(this.selectedPlan()!, billingAddress),
).pipe(
catchError((error: unknown) => {
this.logService.error("Invoice preview failed:", error);
this.toastService.showToast({
variant: "error",
message: this.i18nService.t("invoicePreviewErrorMessage"),
});
return of({ tax: this.INITIAL_TAX_VALUE, total: 0, credit: 0 });
}),
);
}
}

View File

@@ -0,0 +1,198 @@
import { TestBed } from "@angular/core/testing";
import { of } from "rxjs";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BusinessSubscriptionPricingTierIds } from "@bitwarden/common/billing/types/subscription-pricing-tier";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { KeyService } from "@bitwarden/key-management";
import { AccountBillingClient } from "../../../../clients/account-billing.client";
import { PreviewInvoiceClient } from "../../../../clients/preview-invoice.client";
import { BillingAddress } from "../../../../payment/types";
import {
PremiumOrgUpgradePlanDetails,
PremiumOrgUpgradeService,
} from "./premium-org-upgrade.service";
describe("PremiumOrgUpgradeService", () => {
let service: PremiumOrgUpgradeService;
let accountBillingClient: jest.Mocked<AccountBillingClient>;
let previewInvoiceClient: jest.Mocked<PreviewInvoiceClient>;
let syncService: jest.Mocked<SyncService>;
let keyService: jest.Mocked<KeyService>;
const mockAccount = { id: "user-id", email: "test@bitwarden.com" } as Account;
const mockPlanDetails: PremiumOrgUpgradePlanDetails = {
tier: BusinessSubscriptionPricingTierIds.Teams,
details: {
id: BusinessSubscriptionPricingTierIds.Teams,
name: "Teams",
passwordManager: {
annualPrice: 48,
users: 1,
},
},
} as any;
const mockBillingAddress: BillingAddress = {
country: "US",
postalCode: "12345",
line1: null,
line2: null,
city: null,
state: null,
taxId: null,
};
beforeEach(() => {
accountBillingClient = {
upgradePremiumToOrganization: jest.fn().mockResolvedValue(undefined),
} as any;
previewInvoiceClient = {
previewProrationForPremiumUpgrade: jest
.fn()
.mockResolvedValue({ tax: 5, total: 55, credit: 0 }),
} as any;
syncService = {
fullSync: jest.fn().mockResolvedValue(undefined),
} as any;
keyService = {
makeOrgKey: jest.fn().mockResolvedValue(["encrypted-key", "decrypted-key"]),
} as any;
TestBed.configureTestingModule({
providers: [
PremiumOrgUpgradeService,
{ provide: AccountBillingClient, useValue: accountBillingClient },
{ provide: PreviewInvoiceClient, useValue: previewInvoiceClient },
{ provide: SyncService, useValue: syncService },
{ provide: AccountService, useValue: { activeAccount$: of(mockAccount) } },
{ provide: KeyService, useValue: keyService },
],
});
service = TestBed.inject(PremiumOrgUpgradeService);
});
describe("upgradeToOrganization", () => {
it("should successfully upgrade premium account to organization", async () => {
await service.upgradeToOrganization(
mockAccount,
"Test Organization",
mockPlanDetails,
mockBillingAddress,
);
expect(accountBillingClient.upgradePremiumToOrganization).toHaveBeenCalledWith(
"Test Organization",
"encrypted-key",
2, // ProductTierType.Teams
"annually",
mockBillingAddress,
);
expect(keyService.makeOrgKey).toHaveBeenCalledWith("user-id");
expect(syncService.fullSync).toHaveBeenCalledWith(true);
});
it("should throw an error if organization name is missing", async () => {
await expect(
service.upgradeToOrganization(mockAccount, "", mockPlanDetails, mockBillingAddress),
).rejects.toThrow("Organization name is required for organization upgrade");
});
it("should throw an error if billing address is incomplete", async () => {
const incompleteBillingAddress: BillingAddress = {
country: "",
postalCode: "",
line1: null,
line2: null,
city: null,
state: null,
taxId: null,
};
await expect(
service.upgradeToOrganization(
mockAccount,
"Test Organization",
mockPlanDetails,
incompleteBillingAddress,
),
).rejects.toThrow("Billing address information is incomplete");
});
it("should throw an error for invalid plan tier", async () => {
const invalidPlanDetails = {
tier: "invalid-tier" as any,
details: mockPlanDetails.details,
cost: 0,
};
await expect(
service.upgradeToOrganization(
mockAccount,
"Test Organization",
invalidPlanDetails,
mockBillingAddress,
),
).rejects.toThrow("Invalid plan tier for organization upgrade");
});
it("should propagate error if key generation fails", async () => {
keyService.makeOrgKey.mockRejectedValue(new Error("Key generation failed"));
await expect(
service.upgradeToOrganization(
mockAccount,
"Test Organization",
mockPlanDetails,
mockBillingAddress,
),
).rejects.toThrow("Key generation failed");
});
it("should propagate error if upgrade API call fails", async () => {
accountBillingClient.upgradePremiumToOrganization.mockRejectedValue(
new Error("API call failed"),
);
await expect(
service.upgradeToOrganization(
mockAccount,
"Test Organization",
mockPlanDetails,
mockBillingAddress,
),
).rejects.toThrow("API call failed");
});
it("should propagate error if sync fails", async () => {
syncService.fullSync.mockRejectedValue(new Error("Sync failed"));
await expect(
service.upgradeToOrganization(
mockAccount,
"Test Organization",
mockPlanDetails,
mockBillingAddress,
),
).rejects.toThrow("Sync failed");
});
});
describe("previewProratedInvoice", () => {
it("should call previewProrationForPremiumUpgrade and return invoice preview", async () => {
const result = await service.previewProratedInvoice(mockPlanDetails, mockBillingAddress);
expect(result).toEqual({ tax: 5, total: 55, credit: 0 });
expect(previewInvoiceClient.previewProrationForPremiumUpgrade).toHaveBeenCalledWith(
2, // ProductTierType.Teams
mockBillingAddress,
);
});
it("should throw an error if invoice preview fails", async () => {
previewInvoiceClient.previewProrationForPremiumUpgrade.mockRejectedValue(
new Error("Invoice API error"),
);
await expect(
service.previewProratedInvoice(mockPlanDetails, mockBillingAddress),
).rejects.toThrow("Invoice API error");
});
});
});

View File

@@ -0,0 +1,106 @@
import { Injectable } from "@angular/core";
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
import { ProductTierType } from "@bitwarden/common/billing/enums";
import {
BusinessSubscriptionPricingTier,
BusinessSubscriptionPricingTierId,
PersonalSubscriptionPricingTier,
PersonalSubscriptionPricingTierId,
SubscriptionCadenceIds,
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
import { OrgKey } from "@bitwarden/common/types/key";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { KeyService } from "@bitwarden/key-management";
import { AccountBillingClient, PreviewInvoiceClient } from "../../../../clients";
import { BillingAddress } from "../../../../payment/types";
export type PremiumOrgUpgradePlanDetails = {
tier: PersonalSubscriptionPricingTierId | BusinessSubscriptionPricingTierId;
details: PersonalSubscriptionPricingTier | BusinessSubscriptionPricingTier;
cost: number;
};
export type PaymentFormValues = {
organizationName?: string | null;
billingAddress: {
country: string;
postalCode: string;
};
};
export interface InvoicePreview {
tax: number;
total: number;
credit: number;
}
@Injectable()
export class PremiumOrgUpgradeService {
constructor(
private accountBillingClient: AccountBillingClient,
private previewInvoiceClient: PreviewInvoiceClient,
private syncService: SyncService,
private keyService: KeyService,
) {}
async previewProratedInvoice(
planDetails: PremiumOrgUpgradePlanDetails,
billingAddress: BillingAddress,
): Promise<InvoicePreview> {
const tier: ProductTierType = this.ProductTierTypeFromSubscriptionTierId(planDetails.tier);
const invoicePreviewResponse =
await this.previewInvoiceClient.previewProrationForPremiumUpgrade(tier, billingAddress);
return {
tax: invoicePreviewResponse.tax,
total: invoicePreviewResponse.total,
credit: invoicePreviewResponse.credit,
};
}
async upgradeToOrganization(
account: Account,
organizationName: string,
planDetails: PremiumOrgUpgradePlanDetails,
billingAddress: BillingAddress,
): Promise<void> {
if (!organizationName) {
throw new Error("Organization name is required for organization upgrade");
}
if (!billingAddress?.country || !billingAddress?.postalCode) {
throw new Error("Billing address information is incomplete");
}
const tier: ProductTierType = this.ProductTierTypeFromSubscriptionTierId(planDetails.tier);
const [encryptedKey] = await this.keyService.makeOrgKey<OrgKey>(account.id);
await this.accountBillingClient.upgradePremiumToOrganization(
organizationName,
encryptedKey,
tier,
SubscriptionCadenceIds.Annually,
billingAddress,
);
await this.syncService.fullSync(true);
}
private ProductTierTypeFromSubscriptionTierId(
tierId: PersonalSubscriptionPricingTierId | BusinessSubscriptionPricingTierId,
): ProductTierType {
switch (tierId) {
case "families":
return ProductTierType.Families;
case "teams":
return ProductTierType.Teams;
case "enterprise":
return ProductTierType.Enterprise;
default:
throw new Error("Invalid plan tier for organization upgrade");
}
}
}