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:
@@ -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>
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user