mirror of
https://github.com/bitwarden/browser
synced 2026-02-13 23:13:36 +00:00
[PM-29602] Build Upgrade Dialogs (#18539)
* BREAKING CHANGE: rename tax-client and add proration endpoint update * fix(billing)!: rename tax-client in components * feat(billing): Add upgrade endpoint * fix(billing): update preview client error * fix(billing): add billing address to clients * feat(billing): Update messages for changes * feat(biilling): Update unified upgrade dialog logic * feat(billing): add new premium org card * feat(billing): add premium org component * fix(billing): Update account billing client and remove redundant status * fix(billing): unified upgrade dialog add feature flag and tests * fix(billing): update unified upgrade logic * fix(billing): update tests and logic update update fix * fix(billing): add required messages message * fix(billing): update unified dialog logic and re-add comments * feat(billing): improves premium org upgrade dialog Adds a close button to the premium organization upgrade dialog. Updates the success toast message after upgrading to teams. Hides the formatted amount for credit discounts. Sets the change detection strategy to OnPush for improved performance. * fix(billing): prevents multiple upgrade dialogs from opening Adds a check to prevent multiple upgrade dialogs from opening simultaneously. Ensures correct redirection to the organization vault after upgrading to Teams or Enterprise. * Feat(tooltip): Add `showTooltipOnFocus` input to TooltipDirective * Fix(billing): Disable tooltip on focus for various billing buttons * Refactor(billing): Standardize subscription cadence display * Refactor(billing): Update InvoicePreview with prorated amount details * Refactor(billing): Enhance Premium Org Upgrade Payment logic * Feat(billing): Add SubscriptionCadence import to account billing client * refactor(i18n): Rename 'premiumMembershipDiscount' to 'premiumSubscriptionCredit' * fix(billing): Ensure encrypted org key is present during upgrade * refactor(billing): revert PremiumUpgradeDialog focus management * refactor(billing): Clean up subscription details and type definitions * feat(billing): Add dedicated Premium to Organization upgrade dialog * refactor(billing): Return organization ID from PremiumOrgUpgradeService * refactor(billing): Remove premium to org upgrade logic from UnifiedUpgradeDialog * feat(billing): Integrate PremiumOrgUpgradeDialog into account subscription * Refactor: Make `openUpgradeDialog` return `void` * Remove obsolete `planSelectionStepTitleOverride` tests * Feature: Add 'Back' status to UpgradePaymentStatus * Test: Mock `OrganizationService` in `PremiumOrgUpgradePaymentComponent` tests * Chore: Remove redundant comment in unified upgrade dialog HTML * refactor(billing): Remove obsolete unified upgrade change * refactor(billing): remove unused ApiService and DestroyRef * feat(billing): add pre-condition checks for premium org upgrade dialog * refactor(billing): clean up unused dialog data and HTML comment * refactor(billing): rename premium org upgrade dialog flag * feat(billing): close premium org upgrade dialog if feature is disabled * feat(payment): add hideHeader input to DisplayPaymentMethodComponent * refactor(billing): update premium org upgrade payment to display existing payment method * test(billing): update premium org upgrade payment component tests * docs(billing): refine JSDoc for PremiumOrgUpgradeDialogParams * Revert "Feat(tooltip): Add `showTooltipOnFocus` input to TooltipDirective" This reverts commit02f62bc0fd. * Revert "Fix(billing): Disable tooltip on focus for various billing buttons" This reverts commit91f7747df7. * fix(billing): Ensure early exit for closed premium org upgrade payment * refactor: rename PremiumOrgUpgradeComponent to PremiumOrgUpgradePlanSelectionComponent * feat(i18n): add payment method update error translation key * feat(billing): introduce DisplayPaymentMethodInlineComponent * feat(billing): integrate inline payment method in PremiumOrgUpgradePayment * feat(pricing): allow hiding pricing term in cart summary * refactor(billing): optimize invoice preview and update cart configuration * refactor(billing): migrate AccountSubscriptionComponent state to signals * chore(html): improve form field layout and accessibility * feat(pricing): add `hidePricingTerm` input and basic header logic * feat(pricing): apply `hidePricingTerm` to cart item breakdowns * docs(pricing): update cart summary documentation for `hideBreakdown` and `hidePricingTerm` * test(pricing): add tests for `hidePricingTerm` and refine term display selector * refactor(pricing): update cart summary test selectors for robustness * docs: reformat `hideBreakdown` description in `CartSummaryComponent` MDX * refactor: remoe additonal DisplayPaymentMethodInlineComponent in imports * Revert "feat(i18n): add payment method update error translation key" This reverts commitb4aeb74e1a. * feat(i18n): Add payment method update error message * refactor(pricing): move CartSummaryComponent hidePricingTerm to input * docs(pricing): update CartSummaryComponent `hidePricingTerm` usage in MDX * test(pricing): update CartSummaryComponent `hidePricingTerm` tests and stories * chore(pricing): add spacing in CartSummaryComponent spec assertion * refactor(billing): Use ngOnInit for dialog initialization logic * refactor(billing): Migrate hidePricingTerm from Cart type to direct input * Refactor: Update payment method action buttons to use `bitLink` * feat(billing): add hidePricingTerm input to MockCartSummaryComponent
This commit is contained in:
@@ -25,7 +25,7 @@
|
||||
<billing-subscription-card
|
||||
[title]="'premiumMembership' | i18n"
|
||||
[subscription]="subscription"
|
||||
[showUpgradeButton]="premiumToOrganizationUpgradeEnabled()"
|
||||
[showUpgradeButton]="canUpgradeFromPremium()"
|
||||
(callToActionClicked)="onSubscriptionCardAction($event)"
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, resource } from "@angular/core";
|
||||
import { toSignal } from "@angular/core/rxjs-interop";
|
||||
import { ActivatedRoute, Router } from "@angular/router";
|
||||
import { firstValueFrom, lastValueFrom, map } from "rxjs";
|
||||
import { firstValueFrom, lastValueFrom, map, switchMap, of } from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
@@ -39,6 +39,11 @@ import {
|
||||
openOffboardingSurvey,
|
||||
} from "@bitwarden/web-vault/app/billing/shared/offboarding-survey.component";
|
||||
|
||||
import {
|
||||
PremiumOrgUpgradeDialogComponent,
|
||||
PremiumOrgUpgradeDialogParams,
|
||||
} from "../upgrade/premium-org-upgrade-dialog/premium-org-upgrade-dialog.component";
|
||||
|
||||
@Component({
|
||||
templateUrl: "./account-subscription.component.html",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
@@ -65,20 +70,30 @@ export class AccountSubscriptionComponent {
|
||||
private subscriptionPricingService = inject(SubscriptionPricingServiceAbstraction);
|
||||
private toastService = inject(ToastService);
|
||||
|
||||
readonly account = toSignal(this.accountService.activeAccount$);
|
||||
|
||||
readonly hasPremiumPersonally = toSignal(
|
||||
this.accountService.activeAccount$.pipe(
|
||||
switchMap((account) => {
|
||||
if (!account) {
|
||||
return of(false);
|
||||
}
|
||||
return this.billingAccountProfileStateService.hasPremiumPersonally$(account.id);
|
||||
}),
|
||||
),
|
||||
{ initialValue: false },
|
||||
);
|
||||
|
||||
readonly subscription = resource({
|
||||
loader: async () => {
|
||||
const redirectToPremiumPage = async (): Promise<null> => {
|
||||
await this.router.navigate(["/settings/subscription/premium"]);
|
||||
return null;
|
||||
};
|
||||
const account = await firstValueFrom(this.accountService.activeAccount$);
|
||||
if (!account) {
|
||||
if (!this.account()) {
|
||||
return await redirectToPremiumPage();
|
||||
}
|
||||
const hasPremiumPersonally = await firstValueFrom(
|
||||
this.billingAccountProfileStateService.hasPremiumPersonally$(account.id),
|
||||
);
|
||||
if (!hasPremiumPersonally) {
|
||||
if (!this.hasPremiumPersonally()) {
|
||||
return await redirectToPremiumPage();
|
||||
}
|
||||
return await this.accountBillingClient.getSubscription();
|
||||
@@ -177,6 +192,13 @@ export class AccountSubscriptionComponent {
|
||||
{ initialValue: false },
|
||||
);
|
||||
|
||||
readonly canUpgradeFromPremium = computed<boolean>(() => {
|
||||
// Since account is checked in hasPremiumPersonally, no need to check again here
|
||||
const hasPremiumPersonally = this.hasPremiumPersonally();
|
||||
const upgradeEnabled = this.premiumToOrganizationUpgradeEnabled();
|
||||
return hasPremiumPersonally && upgradeEnabled;
|
||||
});
|
||||
|
||||
onSubscriptionCardAction = async (action: SubscriptionCardAction) => {
|
||||
switch (action) {
|
||||
case SubscriptionCardActions.ContactSupport:
|
||||
@@ -209,7 +231,7 @@ export class AccountSubscriptionComponent {
|
||||
await this.router.navigate(["../payment-details"], { relativeTo: this.activatedRoute });
|
||||
break;
|
||||
case SubscriptionCardActions.UpgradePlan:
|
||||
// TODO: Implement upgrade plan navigation
|
||||
await this.openUpgradeDialog();
|
||||
break;
|
||||
}
|
||||
};
|
||||
@@ -288,4 +310,21 @@ export class AccountSubscriptionComponent {
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
openUpgradeDialog = async (): Promise<void> => {
|
||||
const account = this.account();
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dialogParams: PremiumOrgUpgradeDialogParams = {
|
||||
account,
|
||||
redirectOnCompletion: true,
|
||||
};
|
||||
|
||||
const dialogRef = PremiumOrgUpgradeDialogComponent.open(this.dialogService, {
|
||||
data: dialogParams,
|
||||
});
|
||||
await firstValueFrom(dialogRef.closed);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
@if (step() == PlanSelectionStep) {
|
||||
<app-premium-org-upgrade-plan-selection
|
||||
(planSelected)="onPlanSelected($event)"
|
||||
(closeClicked)="onCloseClicked()"
|
||||
/>
|
||||
} @else if (step() == PaymentStep && selectedPlan() !== null && account() !== null) {
|
||||
<app-premium-org-upgrade-payment
|
||||
[selectedPlanId]="selectedPlan()"
|
||||
[account]="account()"
|
||||
(goBack)="previousStep()"
|
||||
(complete)="onComplete($event)"
|
||||
/>
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
import { ChangeDetectionStrategy, Component, input, output } from "@angular/core";
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { NoopAnimationsModule } from "@angular/platform-browser/animations";
|
||||
import { Router } from "@angular/router";
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
|
||||
import {
|
||||
BusinessSubscriptionPricingTierId,
|
||||
PersonalSubscriptionPricingTierId,
|
||||
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { mockAccountInfoWith } from "@bitwarden/common/spec";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { DIALOG_DATA, DialogRef } from "@bitwarden/components";
|
||||
|
||||
import {
|
||||
PremiumOrgUpgradePaymentComponent,
|
||||
PremiumOrgUpgradePaymentResult,
|
||||
PremiumOrgUpgradePaymentStatus,
|
||||
} from "../premium-org-upgrade-payment/premium-org-upgrade-payment.component";
|
||||
import { PremiumOrgUpgradePlanSelectionComponent } from "../premium-org-upgrade-plan-selection/premium-org-upgrade-plan-selection.component";
|
||||
|
||||
import {
|
||||
PremiumOrgUpgradeDialogComponent,
|
||||
PremiumOrgUpgradeDialogParams,
|
||||
PremiumOrgUpgradeDialogStep,
|
||||
} from "./premium-org-upgrade-dialog.component";
|
||||
|
||||
@Component({
|
||||
selector: "app-premium-org-upgrade-plan-selection",
|
||||
template: "",
|
||||
standalone: true,
|
||||
providers: [PremiumOrgUpgradePlanSelectionComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
class MockPremiumOrgUpgradePlanSelectionComponent {
|
||||
readonly dialogTitleMessageOverride = input<string | null>(null);
|
||||
readonly hideContinueWithoutUpgradingButton = input<boolean>(false);
|
||||
planSelected = output<BusinessSubscriptionPricingTierId>();
|
||||
closeClicked = output<PremiumOrgUpgradePaymentStatus>();
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: "app-premium-org-upgrade-payment",
|
||||
template: "",
|
||||
standalone: true,
|
||||
providers: [PremiumOrgUpgradePaymentComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
class MockPremiumOrgUpgradePaymentComponent {
|
||||
readonly selectedPlanId = input<
|
||||
BusinessSubscriptionPricingTierId | PersonalSubscriptionPricingTierId | null
|
||||
>(null);
|
||||
readonly account = input<Account | null>(null);
|
||||
goBack = output<void>();
|
||||
complete = output<{ status: PremiumOrgUpgradePaymentStatus; organizationId: string | null }>();
|
||||
}
|
||||
|
||||
describe("PremiumOrgUpgradeDialogComponent", () => {
|
||||
let component: PremiumOrgUpgradeDialogComponent;
|
||||
let fixture: ComponentFixture<PremiumOrgUpgradeDialogComponent>;
|
||||
const mockDialogRef = mock<DialogRef>();
|
||||
const mockRouter = mock<Router>();
|
||||
const mockBillingAccountProfileStateService = mock<BillingAccountProfileStateService>();
|
||||
const mockConfigService = mock<ConfigService>();
|
||||
const mockAccount: Account = {
|
||||
id: "user-id" as UserId,
|
||||
...mockAccountInfoWith({
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
}),
|
||||
};
|
||||
|
||||
const defaultDialogData: PremiumOrgUpgradeDialogParams = {
|
||||
account: mockAccount,
|
||||
initialStep: null,
|
||||
selectedPlan: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function to create and configure a fresh component instance with custom dialog data
|
||||
*/
|
||||
async function createComponentWithDialogData(
|
||||
dialogData: PremiumOrgUpgradeDialogParams,
|
||||
waitForStable = false,
|
||||
): Promise<{
|
||||
fixture: ComponentFixture<PremiumOrgUpgradeDialogComponent>;
|
||||
component: PremiumOrgUpgradeDialogComponent;
|
||||
}> {
|
||||
TestBed.resetTestingModule();
|
||||
jest.clearAllMocks();
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [NoopAnimationsModule, PremiumOrgUpgradeDialogComponent],
|
||||
providers: [
|
||||
{ provide: DialogRef, useValue: mockDialogRef },
|
||||
{ provide: DIALOG_DATA, useValue: dialogData },
|
||||
{ provide: Router, useValue: mockRouter },
|
||||
{
|
||||
provide: BillingAccountProfileStateService,
|
||||
useValue: mockBillingAccountProfileStateService,
|
||||
},
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
],
|
||||
})
|
||||
.overrideComponent(PremiumOrgUpgradeDialogComponent, {
|
||||
remove: {
|
||||
imports: [PremiumOrgUpgradePlanSelectionComponent, PremiumOrgUpgradePaymentComponent],
|
||||
},
|
||||
add: {
|
||||
imports: [
|
||||
MockPremiumOrgUpgradePlanSelectionComponent,
|
||||
MockPremiumOrgUpgradePaymentComponent,
|
||||
],
|
||||
},
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
const newFixture = TestBed.createComponent(PremiumOrgUpgradeDialogComponent);
|
||||
const newComponent = newFixture.componentInstance;
|
||||
newFixture.detectChanges();
|
||||
|
||||
if (waitForStable) {
|
||||
await newFixture.whenStable();
|
||||
}
|
||||
|
||||
return { fixture: newFixture, component: newComponent };
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
// Reset mocks
|
||||
jest.clearAllMocks();
|
||||
|
||||
mockBillingAccountProfileStateService.hasPremiumPersonally$.mockReturnValue(of(true));
|
||||
mockConfigService.getFeatureFlag$.mockReturnValue(of(true));
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [PremiumOrgUpgradeDialogComponent],
|
||||
providers: [
|
||||
{ provide: DialogRef, useValue: mockDialogRef },
|
||||
{ provide: DIALOG_DATA, useValue: defaultDialogData },
|
||||
{ provide: Router, useValue: mockRouter },
|
||||
{
|
||||
provide: BillingAccountProfileStateService,
|
||||
useValue: mockBillingAccountProfileStateService,
|
||||
},
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
],
|
||||
})
|
||||
.overrideComponent(PremiumOrgUpgradeDialogComponent, {
|
||||
remove: {
|
||||
imports: [PremiumOrgUpgradePlanSelectionComponent, PremiumOrgUpgradePaymentComponent],
|
||||
},
|
||||
add: {
|
||||
imports: [
|
||||
MockPremiumOrgUpgradePlanSelectionComponent,
|
||||
MockPremiumOrgUpgradePaymentComponent,
|
||||
],
|
||||
},
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(PremiumOrgUpgradeDialogComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it("should create", () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should initialize with default values", () => {
|
||||
expect(component["step"]()).toBe(PremiumOrgUpgradeDialogStep.PlanSelection);
|
||||
expect(component["selectedPlan"]()).toBeNull();
|
||||
expect(component["account"]()).toEqual(mockAccount);
|
||||
});
|
||||
|
||||
it("should initialize with custom initial step", async () => {
|
||||
const customDialogData: PremiumOrgUpgradeDialogParams = {
|
||||
account: mockAccount,
|
||||
initialStep: PremiumOrgUpgradeDialogStep.Payment,
|
||||
selectedPlan: "teams" as BusinessSubscriptionPricingTierId,
|
||||
};
|
||||
|
||||
const { component: customComponent } = await createComponentWithDialogData(customDialogData);
|
||||
|
||||
expect(customComponent["step"]()).toBe(PremiumOrgUpgradeDialogStep.Payment);
|
||||
expect(customComponent["selectedPlan"]()).toBe("teams");
|
||||
});
|
||||
|
||||
describe("onPlanSelected", () => {
|
||||
it("should set selected plan and move to payment step", () => {
|
||||
component["onPlanSelected"]("teams" as BusinessSubscriptionPricingTierId);
|
||||
|
||||
expect(component["selectedPlan"]()).toBe("teams");
|
||||
expect(component["step"]()).toBe(PremiumOrgUpgradeDialogStep.Payment);
|
||||
});
|
||||
|
||||
it("should handle selecting Enterprise plan", () => {
|
||||
component["onPlanSelected"]("enterprise" as BusinessSubscriptionPricingTierId);
|
||||
|
||||
expect(component["selectedPlan"]()).toBe("enterprise");
|
||||
expect(component["step"]()).toBe(PremiumOrgUpgradeDialogStep.Payment);
|
||||
});
|
||||
});
|
||||
|
||||
describe("previousStep", () => {
|
||||
it("should go back to plan selection and clear selected plan", async () => {
|
||||
component["step"].set(PremiumOrgUpgradeDialogStep.Payment);
|
||||
component["selectedPlan"].set("teams" as BusinessSubscriptionPricingTierId);
|
||||
|
||||
await component["previousStep"]();
|
||||
|
||||
expect(component["step"]()).toBe(PremiumOrgUpgradeDialogStep.PlanSelection);
|
||||
expect(component["selectedPlan"]()).toBeNull();
|
||||
});
|
||||
|
||||
it("should close dialog when backing out from initial step", async () => {
|
||||
const customDialogData: PremiumOrgUpgradeDialogParams = {
|
||||
account: mockAccount,
|
||||
initialStep: PremiumOrgUpgradeDialogStep.Payment,
|
||||
selectedPlan: "teams" as BusinessSubscriptionPricingTierId,
|
||||
};
|
||||
|
||||
const { component: customComponent } = await createComponentWithDialogData(customDialogData);
|
||||
|
||||
await customComponent["previousStep"]();
|
||||
|
||||
expect(mockDialogRef.close).toHaveBeenCalledWith({ status: "closed" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("onComplete", () => {
|
||||
it("should handle completing upgrade to Families successfully", async () => {
|
||||
const { component: testComponent } = await createComponentWithDialogData(defaultDialogData);
|
||||
mockRouter.navigate.mockResolvedValue(true);
|
||||
|
||||
const result = {
|
||||
status: "upgradedToFamilies" as const,
|
||||
organizationId: "org-111",
|
||||
};
|
||||
|
||||
await testComponent["onComplete"](result);
|
||||
|
||||
expect(mockDialogRef.close).toHaveBeenCalledWith({
|
||||
status: "upgradedToFamilies",
|
||||
organizationId: "org-111",
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle completing upgrade to Teams successfully", async () => {
|
||||
const { component: testComponent } = await createComponentWithDialogData(defaultDialogData);
|
||||
mockRouter.navigate.mockResolvedValue(true);
|
||||
|
||||
const result = {
|
||||
status: "upgradedToTeams" as const,
|
||||
organizationId: "org-123",
|
||||
};
|
||||
|
||||
await testComponent["onComplete"](result);
|
||||
|
||||
expect(mockDialogRef.close).toHaveBeenCalledWith({
|
||||
status: "upgradedToTeams",
|
||||
organizationId: "org-123",
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle completing upgrade to Enterprise successfully", async () => {
|
||||
const { component: testComponent } = await createComponentWithDialogData(defaultDialogData);
|
||||
mockRouter.navigate.mockResolvedValue(true);
|
||||
|
||||
const result = {
|
||||
status: "upgradedToEnterprise" as const,
|
||||
organizationId: "org-456",
|
||||
};
|
||||
|
||||
await testComponent["onComplete"](result);
|
||||
|
||||
expect(mockDialogRef.close).toHaveBeenCalledWith({
|
||||
status: "upgradedToEnterprise",
|
||||
organizationId: "org-456",
|
||||
});
|
||||
});
|
||||
|
||||
it("should redirect to organization vault after Teams upgrade when redirectOnCompletion is true", async () => {
|
||||
const customDialogData: PremiumOrgUpgradeDialogParams = {
|
||||
account: mockAccount,
|
||||
redirectOnCompletion: true,
|
||||
};
|
||||
|
||||
mockRouter.navigate.mockResolvedValue(true);
|
||||
|
||||
const { component: customComponent } = await createComponentWithDialogData(customDialogData);
|
||||
|
||||
const result = {
|
||||
status: "upgradedToTeams" as const,
|
||||
organizationId: "org-123",
|
||||
};
|
||||
|
||||
await customComponent["onComplete"](result);
|
||||
|
||||
expect(mockRouter.navigate).toHaveBeenCalledWith(["/organizations/org-123/vault"]);
|
||||
expect(mockDialogRef.close).toHaveBeenCalledWith({
|
||||
status: "upgradedToTeams",
|
||||
organizationId: "org-123",
|
||||
});
|
||||
});
|
||||
|
||||
it("should redirect to organization vault after Enterprise upgrade when redirectOnCompletion is true", async () => {
|
||||
const customDialogData: PremiumOrgUpgradeDialogParams = {
|
||||
account: mockAccount,
|
||||
redirectOnCompletion: true,
|
||||
};
|
||||
|
||||
mockRouter.navigate.mockResolvedValue(true);
|
||||
|
||||
const { component: customComponent } = await createComponentWithDialogData(customDialogData);
|
||||
|
||||
const result = {
|
||||
status: "upgradedToEnterprise" as const,
|
||||
organizationId: "org-789",
|
||||
};
|
||||
|
||||
await customComponent["onComplete"](result);
|
||||
|
||||
expect(mockRouter.navigate).toHaveBeenCalledWith(["/organizations/org-789/vault"]);
|
||||
expect(mockDialogRef.close).toHaveBeenCalledWith({
|
||||
status: "upgradedToEnterprise",
|
||||
organizationId: "org-789",
|
||||
});
|
||||
});
|
||||
|
||||
it("should redirect to organization vault after Families upgrade when redirectOnCompletion is true", async () => {
|
||||
const customDialogData: PremiumOrgUpgradeDialogParams = {
|
||||
account: mockAccount,
|
||||
redirectOnCompletion: true,
|
||||
};
|
||||
|
||||
mockRouter.navigate.mockResolvedValue(true);
|
||||
|
||||
const { component: customComponent } = await createComponentWithDialogData(customDialogData);
|
||||
|
||||
const result = {
|
||||
status: "upgradedToFamilies" as const,
|
||||
organizationId: "org-999",
|
||||
};
|
||||
|
||||
await customComponent["onComplete"](result);
|
||||
|
||||
expect(mockRouter.navigate).toHaveBeenCalledWith(["/organizations/org-999/vault"]);
|
||||
expect(mockDialogRef.close).toHaveBeenCalledWith({
|
||||
status: "upgradedToFamilies",
|
||||
organizationId: "org-999",
|
||||
});
|
||||
});
|
||||
|
||||
it("should not redirect when redirectOnCompletion is false", async () => {
|
||||
const customDialogData: PremiumOrgUpgradeDialogParams = {
|
||||
account: mockAccount,
|
||||
redirectOnCompletion: false,
|
||||
};
|
||||
|
||||
const { component: customComponent } = await createComponentWithDialogData(customDialogData);
|
||||
|
||||
const result = {
|
||||
status: "upgradedToTeams" as const,
|
||||
organizationId: "org-123",
|
||||
};
|
||||
|
||||
await customComponent["onComplete"](result);
|
||||
|
||||
expect(mockRouter.navigate).not.toHaveBeenCalled();
|
||||
expect(mockDialogRef.close).toHaveBeenCalledWith({
|
||||
status: "upgradedToTeams",
|
||||
organizationId: "org-123",
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle closed status", async () => {
|
||||
const { component: testComponent } = await createComponentWithDialogData(defaultDialogData);
|
||||
|
||||
const result: PremiumOrgUpgradePaymentResult = { status: "closed", organizationId: null };
|
||||
|
||||
await testComponent["onComplete"](result);
|
||||
|
||||
expect(mockDialogRef.close).toHaveBeenCalledWith({
|
||||
status: "closed",
|
||||
organizationId: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("onCloseClicked", () => {
|
||||
it("should close dialog", async () => {
|
||||
await component["onCloseClicked"]();
|
||||
|
||||
expect(mockDialogRef.close).toHaveBeenCalledWith({ status: "closed" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Premium and Feature Flag Requirements", () => {
|
||||
it("should close dialog immediately if user does not have premium", async () => {
|
||||
mockBillingAccountProfileStateService.hasPremiumPersonally$.mockReturnValue(of(false));
|
||||
mockConfigService.getFeatureFlag$.mockReturnValue(of(true));
|
||||
|
||||
await createComponentWithDialogData(defaultDialogData, true);
|
||||
|
||||
expect(mockDialogRef.close).toHaveBeenCalledWith({ status: "closed" });
|
||||
});
|
||||
|
||||
it("should close dialog immediately if feature flag is not enabled", async () => {
|
||||
mockBillingAccountProfileStateService.hasPremiumPersonally$.mockReturnValue(of(true));
|
||||
mockConfigService.getFeatureFlag$.mockReturnValue(of(false));
|
||||
|
||||
await createComponentWithDialogData(defaultDialogData, true);
|
||||
|
||||
expect(mockDialogRef.close).toHaveBeenCalledWith({ status: "closed" });
|
||||
});
|
||||
|
||||
it("should close dialog immediately if user does not have premium and feature flag is not enabled", async () => {
|
||||
mockBillingAccountProfileStateService.hasPremiumPersonally$.mockReturnValue(of(false));
|
||||
mockConfigService.getFeatureFlag$.mockReturnValue(of(false));
|
||||
|
||||
await createComponentWithDialogData(defaultDialogData, true);
|
||||
|
||||
expect(mockDialogRef.close).toHaveBeenCalledWith({ status: "closed" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Child Component Display Logic", () => {
|
||||
describe("Plan Selection Step", () => {
|
||||
it("should display app-premium-org-upgrade-plan-selection on plan selection step", async () => {
|
||||
const { fixture } = await createComponentWithDialogData(defaultDialogData);
|
||||
|
||||
const premiumOrgUpgradeElement = fixture.nativeElement.querySelector(
|
||||
"app-premium-org-upgrade-plan-selection",
|
||||
);
|
||||
|
||||
expect(premiumOrgUpgradeElement).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Payment Step", () => {
|
||||
it("should display app-premium-org-upgrade-payment on payment step", async () => {
|
||||
const customDialogData: PremiumOrgUpgradeDialogParams = {
|
||||
account: mockAccount,
|
||||
initialStep: PremiumOrgUpgradeDialogStep.Payment,
|
||||
selectedPlan: "teams" as BusinessSubscriptionPricingTierId,
|
||||
};
|
||||
|
||||
const { fixture } = await createComponentWithDialogData(customDialogData);
|
||||
|
||||
const premiumOrgUpgradePaymentElement = fixture.nativeElement.querySelector(
|
||||
"app-premium-org-upgrade-payment",
|
||||
);
|
||||
|
||||
expect(premiumOrgUpgradePaymentElement).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
import { DIALOG_DATA } from "@angular/cdk/dialog";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
Inject,
|
||||
OnInit,
|
||||
signal,
|
||||
} from "@angular/core";
|
||||
import { toSignal } from "@angular/core/rxjs-interop";
|
||||
import { Router } from "@angular/router";
|
||||
|
||||
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
|
||||
import {
|
||||
BusinessSubscriptionPricingTierId,
|
||||
PersonalSubscriptionPricingTierId,
|
||||
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values";
|
||||
import {
|
||||
ButtonModule,
|
||||
DialogConfig,
|
||||
DialogModule,
|
||||
DialogRef,
|
||||
DialogService,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
import { AccountBillingClient, PreviewInvoiceClient } from "../../../clients";
|
||||
import { BillingServicesModule } from "../../../services";
|
||||
import {
|
||||
PremiumOrgUpgradePaymentComponent,
|
||||
PremiumOrgUpgradePaymentResult,
|
||||
} from "../premium-org-upgrade-payment/premium-org-upgrade-payment.component";
|
||||
import { PremiumOrgUpgradePlanSelectionComponent } from "../premium-org-upgrade-plan-selection/premium-org-upgrade-plan-selection.component";
|
||||
import { UpgradePaymentService } from "../upgrade-payment/services/upgrade-payment.service";
|
||||
|
||||
export const PremiumOrgUpgradeDialogStatus = {
|
||||
Closed: "closed",
|
||||
UpgradedToFamilies: "upgradedToFamilies",
|
||||
UpgradedToTeams: "upgradedToTeams",
|
||||
UpgradedToEnterprise: "upgradedToEnterprise",
|
||||
} as const;
|
||||
|
||||
export const PremiumOrgUpgradeDialogStep = {
|
||||
PlanSelection: "planSelection",
|
||||
Payment: "payment",
|
||||
} as const;
|
||||
|
||||
export type PremiumOrgUpgradeDialogStatus = UnionOfValues<typeof PremiumOrgUpgradeDialogStatus>;
|
||||
export type PremiumOrgUpgradeDialogStep = UnionOfValues<typeof PremiumOrgUpgradeDialogStep>;
|
||||
|
||||
export type PremiumOrgUpgradeDialogResult = {
|
||||
status: PremiumOrgUpgradeDialogStatus;
|
||||
organizationId?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parameters for the PremiumOrgUpgradeDialog component.
|
||||
* In order to open the dialog to a specific step, you must provide the `initialStep` parameter and a `selectedPlan` if the step is `Payment`.
|
||||
*
|
||||
* @property {Account} account - The user account information.
|
||||
* @property {PremiumOrgUpgradeDialogStep | null} [initialStep] - The initial step to open the dialog to, if any.
|
||||
* @property {BusinessSubscriptionPricingTierId | PersonalSubscriptionPricingTierId | null} [selectedPlan] - Pre-selected subscription plan, if any.
|
||||
* @property {boolean} [redirectOnCompletion] - Whether to redirect after successful upgrade to organization vault.
|
||||
*/
|
||||
export type PremiumOrgUpgradeDialogParams = {
|
||||
account: Account;
|
||||
initialStep?: PremiumOrgUpgradeDialogStep | null;
|
||||
selectedPlan?: BusinessSubscriptionPricingTierId | PersonalSubscriptionPricingTierId | null;
|
||||
redirectOnCompletion?: boolean;
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: "app-premium-org-upgrade-dialog",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
CommonModule,
|
||||
DialogModule,
|
||||
ButtonModule,
|
||||
BillingServicesModule,
|
||||
PremiumOrgUpgradePlanSelectionComponent,
|
||||
PremiumOrgUpgradePaymentComponent,
|
||||
],
|
||||
providers: [UpgradePaymentService, AccountBillingClient, PreviewInvoiceClient],
|
||||
templateUrl: "./premium-org-upgrade-dialog.component.html",
|
||||
})
|
||||
export class PremiumOrgUpgradeDialogComponent implements OnInit {
|
||||
// Use signals for dialog state because inputs depend on parent component
|
||||
protected readonly step = signal<PremiumOrgUpgradeDialogStep>(
|
||||
PremiumOrgUpgradeDialogStep.PlanSelection,
|
||||
);
|
||||
protected readonly selectedPlan = signal<
|
||||
BusinessSubscriptionPricingTierId | PersonalSubscriptionPricingTierId | null
|
||||
>(null);
|
||||
protected readonly account = signal<Account | null>(null);
|
||||
protected readonly hasPremiumPersonally = toSignal(
|
||||
this.billingAccountProfileStateService.hasPremiumPersonally$(this.params.account.id),
|
||||
{ initialValue: false },
|
||||
);
|
||||
protected readonly premiumToOrganizationUpgradeEnabled = toSignal(
|
||||
this.configService.getFeatureFlag$(FeatureFlag.PM29593_PremiumToOrganizationUpgrade),
|
||||
{ initialValue: false },
|
||||
);
|
||||
protected readonly showPremiumToOrganizationUpgrade = computed(
|
||||
() => this.hasPremiumPersonally() && this.premiumToOrganizationUpgradeEnabled(),
|
||||
);
|
||||
|
||||
protected readonly PaymentStep = PremiumOrgUpgradeDialogStep.Payment;
|
||||
protected readonly PlanSelectionStep = PremiumOrgUpgradeDialogStep.PlanSelection;
|
||||
|
||||
constructor(
|
||||
private dialogRef: DialogRef<PremiumOrgUpgradeDialogResult>,
|
||||
@Inject(DIALOG_DATA) private params: PremiumOrgUpgradeDialogParams,
|
||||
private router: Router,
|
||||
private billingAccountProfileStateService: BillingAccountProfileStateService,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
if (!this.showPremiumToOrganizationUpgrade()) {
|
||||
// If the premium to organization upgrade feature is not enabled or user does not have premium personally, close the dialog
|
||||
this.close({ status: PremiumOrgUpgradeDialogStatus.Closed });
|
||||
return;
|
||||
}
|
||||
this.account.set(this.params.account);
|
||||
this.step.set(this.params.initialStep ?? PremiumOrgUpgradeDialogStep.PlanSelection);
|
||||
this.selectedPlan.set(this.params.selectedPlan ?? null);
|
||||
}
|
||||
|
||||
protected onPlanSelected(
|
||||
planId: BusinessSubscriptionPricingTierId | PersonalSubscriptionPricingTierId,
|
||||
): void {
|
||||
this.selectedPlan.set(planId);
|
||||
this.nextStep();
|
||||
}
|
||||
|
||||
protected async onCloseClicked(): Promise<void> {
|
||||
this.close({ status: PremiumOrgUpgradeDialogStatus.Closed });
|
||||
}
|
||||
|
||||
private close(result: PremiumOrgUpgradeDialogResult): void {
|
||||
this.dialogRef.close(result);
|
||||
}
|
||||
|
||||
protected nextStep() {
|
||||
if (this.step() === PremiumOrgUpgradeDialogStep.PlanSelection) {
|
||||
this.step.set(PremiumOrgUpgradeDialogStep.Payment);
|
||||
}
|
||||
}
|
||||
|
||||
protected async previousStep(): Promise<void> {
|
||||
// If we are on the payment step and there was no initial step, go back to plan selection this is to prevent
|
||||
// going back to payment step if the dialog was opened directly to payment step
|
||||
if (this.step() === PremiumOrgUpgradeDialogStep.Payment && this.params?.initialStep == null) {
|
||||
this.step.set(PremiumOrgUpgradeDialogStep.PlanSelection);
|
||||
this.selectedPlan.set(null);
|
||||
} else {
|
||||
this.close({ status: PremiumOrgUpgradeDialogStatus.Closed });
|
||||
}
|
||||
}
|
||||
|
||||
protected async onComplete(result: PremiumOrgUpgradePaymentResult): Promise<void> {
|
||||
let status: PremiumOrgUpgradeDialogStatus;
|
||||
switch (result.status) {
|
||||
case "upgradedToFamilies":
|
||||
status = PremiumOrgUpgradeDialogStatus.UpgradedToFamilies;
|
||||
break;
|
||||
case "upgradedToTeams":
|
||||
status = PremiumOrgUpgradeDialogStatus.UpgradedToTeams;
|
||||
break;
|
||||
case "upgradedToEnterprise":
|
||||
status = PremiumOrgUpgradeDialogStatus.UpgradedToEnterprise;
|
||||
break;
|
||||
case "closed":
|
||||
status = PremiumOrgUpgradeDialogStatus.Closed;
|
||||
break;
|
||||
default:
|
||||
status = PremiumOrgUpgradeDialogStatus.Closed;
|
||||
}
|
||||
|
||||
this.close({ status, organizationId: result.organizationId });
|
||||
|
||||
// Redirect to organization vault after successful upgrade
|
||||
if (
|
||||
this.params.redirectOnCompletion &&
|
||||
(status === PremiumOrgUpgradeDialogStatus.UpgradedToFamilies ||
|
||||
status === PremiumOrgUpgradeDialogStatus.UpgradedToEnterprise ||
|
||||
status === PremiumOrgUpgradeDialogStatus.UpgradedToTeams)
|
||||
) {
|
||||
const redirectUrl = `/organizations/${result.organizationId}/vault`;
|
||||
await this.router.navigate([redirectUrl]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the premium org upgrade dialog.
|
||||
*
|
||||
* @param dialogService - The dialog service used to open the component
|
||||
* @param dialogConfig - The configuration for the dialog including PremiumOrgUpgradeDialogParams data
|
||||
* @returns A dialog reference object of type DialogRef<PremiumOrgUpgradeDialogResult>
|
||||
*/
|
||||
static open(
|
||||
dialogService: DialogService,
|
||||
dialogConfig: DialogConfig<PremiumOrgUpgradeDialogParams>,
|
||||
): DialogRef<PremiumOrgUpgradeDialogResult> {
|
||||
return dialogService.open<PremiumOrgUpgradeDialogResult>(PremiumOrgUpgradeDialogComponent, {
|
||||
data: dialogConfig.data,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<form [formGroup]="formGroup" [bitSubmit]="submit">
|
||||
@let changingPayment = isChangingPaymentMethod();
|
||||
<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-hint bitTypography="helper" class="tw-text-muted">
|
||||
{{ "organizationNameDescription" | i18n }}
|
||||
</bit-hint>
|
||||
</bit-form-field>
|
||||
</div>
|
||||
|
||||
<div class="tw-pb-8 !tw-mx-0">
|
||||
<app-display-payment-method-inline
|
||||
[subscriber]="subscriber()"
|
||||
[paymentMethod]="paymentMethod()"
|
||||
(updated)="handlePaymentMethodUpdate($event)"
|
||||
(changingStateChanged)="handlePaymentMethodChangingStateChange($event)"
|
||||
>
|
||||
</app-display-payment-method-inline>
|
||||
@if (!changingPayment) {
|
||||
<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()"
|
||||
[hidePricingTerm]="true"
|
||||
></billing-cart-summary>
|
||||
</section>
|
||||
</ng-container>
|
||||
<ng-container bitDialogFooter>
|
||||
<button
|
||||
bitButton
|
||||
bitFormButton
|
||||
buttonType="primary"
|
||||
[disabled]="loading() || !formGroup.valid"
|
||||
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,576 @@
|
||||
import {
|
||||
Component,
|
||||
input,
|
||||
ChangeDetectionStrategy,
|
||||
CUSTOM_ELEMENTS_SCHEMA,
|
||||
signal,
|
||||
output,
|
||||
} 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 { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { Account, AccountService } 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 { SubscriberBillingClient } from "../../../clients/subscriber-billing.client";
|
||||
import {
|
||||
EnterBillingAddressComponent,
|
||||
DisplayPaymentMethodInlineComponent,
|
||||
} 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);
|
||||
readonly hidePricingTerm = input<boolean>(false);
|
||||
}
|
||||
|
||||
@Component({
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
selector: "app-display-payment-method-inline",
|
||||
template: `<h1>Mock Display Payment Method</h1>`,
|
||||
providers: [
|
||||
{
|
||||
provide: DisplayPaymentMethodInlineComponent,
|
||||
useClass: MockDisplayPaymentMethodInlineComponent,
|
||||
},
|
||||
],
|
||||
})
|
||||
class MockDisplayPaymentMethodInlineComponent {
|
||||
readonly subscriber = input.required<any>();
|
||||
readonly paymentMethod = input<any>();
|
||||
readonly updated = output<any>();
|
||||
readonly changePaymentMethodClicked = output<void>();
|
||||
}
|
||||
|
||||
@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 mockOrganizationService = mock<OrganizationService>();
|
||||
const mockSubscriberBillingClient = mock<SubscriberBillingClient>();
|
||||
const mockApiService = mock<ApiService>();
|
||||
const mockAccountService = mock<AccountService>();
|
||||
const mockI18nService = { t: jest.fn((key: string, ...params: any[]) => key) };
|
||||
|
||||
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);
|
||||
mockPremiumOrgUpgradeService.previewProratedInvoice.mockResolvedValue({
|
||||
tax: 5.0,
|
||||
total: 53.0,
|
||||
credit: 10.0,
|
||||
newPlanProratedMonths: 1,
|
||||
});
|
||||
mockOrganizationService.organizations$.mockReturnValue(of([]));
|
||||
mockAccountService.activeAccount$ = of(mockAccount);
|
||||
mockSubscriberBillingClient.getPaymentMethod.mockResolvedValue({
|
||||
type: "card",
|
||||
brand: "visa",
|
||||
last4: "4242",
|
||||
expiration: "12/2025",
|
||||
});
|
||||
|
||||
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: mockI18nService },
|
||||
{ provide: AccountBillingClient, useValue: mockAccountBillingClient },
|
||||
{ provide: PreviewInvoiceClient, useValue: mockPreviewInvoiceClient },
|
||||
{ provide: SubscriberBillingClient, useValue: mockSubscriberBillingClient },
|
||||
{ provide: AccountService, useValue: mockAccountService },
|
||||
{ provide: ApiService, useValue: mockApiService },
|
||||
{
|
||||
provide: KeyService,
|
||||
useValue: {
|
||||
makeOrgKey: jest.fn().mockResolvedValue(["encrypted-key", "decrypted-key"]),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: SyncService,
|
||||
useValue: { fullSync: jest.fn().mockResolvedValue(undefined) },
|
||||
},
|
||||
{ provide: OrganizationService, useValue: mockOrganizationService },
|
||||
],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
||||
})
|
||||
.overrideComponent(PremiumOrgUpgradePaymentComponent, {
|
||||
add: {
|
||||
imports: [
|
||||
MockEnterBillingAddressComponent,
|
||||
MockDisplayPaymentMethodInlineComponent,
|
||||
MockCartSummaryComponent,
|
||||
],
|
||||
},
|
||||
remove: {
|
||||
imports: [
|
||||
EnterBillingAddressComponent,
|
||||
DisplayPaymentMethodInlineComponent,
|
||||
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("upgradeToTeams");
|
||||
});
|
||||
|
||||
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 processUpgrade to bypass form validation
|
||||
jest.spyOn(component as any, "processUpgrade").mockResolvedValue({
|
||||
status: PremiumOrgUpgradePaymentStatus.UpgradedToTeams,
|
||||
organizationId: null,
|
||||
});
|
||||
|
||||
component["formGroup"].setValue({
|
||||
organizationName: "My New Org",
|
||||
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: "plansUpdated",
|
||||
});
|
||||
expect(completeSpy).toHaveBeenCalledWith({
|
||||
status: PremiumOrgUpgradePaymentStatus.UpgradedToTeams,
|
||||
organizationId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("should show an error toast if upgrade fails", async () => {
|
||||
// Mock processUpgrade to throw an error
|
||||
jest
|
||||
.spyOn(component as any, "processUpgrade")
|
||||
.mockRejectedValue(new Error("Submission Error"));
|
||||
|
||||
component["formGroup"].setValue({
|
||||
organizationName: "My New Org",
|
||||
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,
|
||||
);
|
||||
});
|
||||
|
||||
describe("Invoice Preview", () => {
|
||||
it("should return zero values when billing address is incomplete", fakeAsync(() => {
|
||||
component["formGroup"].patchValue({
|
||||
organizationName: "Test Org",
|
||||
billingAddress: {
|
||||
country: "US",
|
||||
postalCode: "", // Missing postal code
|
||||
},
|
||||
});
|
||||
|
||||
// Advance time to allow any async operations to complete
|
||||
tick(1500);
|
||||
fixture.detectChanges();
|
||||
|
||||
const estimatedInvoice = component["estimatedInvoice"]();
|
||||
expect(estimatedInvoice.tax).toBe(0);
|
||||
expect(estimatedInvoice.total).toBe(0);
|
||||
}));
|
||||
});
|
||||
|
||||
describe("Form Validation", () => {
|
||||
it("should validate organization name is required", () => {
|
||||
component["formGroup"].patchValue({ organizationName: "" });
|
||||
expect(component["formGroup"].get("organizationName")?.invalid).toBe(true);
|
||||
});
|
||||
|
||||
it("should validate organization name when provided", () => {
|
||||
component["formGroup"].patchValue({ organizationName: "My Organization" });
|
||||
expect(component["formGroup"].get("organizationName")?.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Cart Calculation", () => {
|
||||
it("should calculate cart with correct values for selected plan", () => {
|
||||
const cart = component["cart"]();
|
||||
expect(cart.passwordManager.seats.cost).toBe(48); // Teams annual price per user
|
||||
expect(cart.passwordManager.seats.quantity).toBe(1);
|
||||
expect(cart.cadence).toBe("annually");
|
||||
});
|
||||
|
||||
it("should return default cart when no plan is selected", () => {
|
||||
component["selectedPlan"].set(null);
|
||||
const cart = component["cart"]();
|
||||
|
||||
expect(cart.passwordManager.seats.cost).toBe(0);
|
||||
expect(cart.passwordManager.seats.quantity).toBe(0);
|
||||
expect(cart.estimatedTax).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ngAfterViewInit", () => {
|
||||
it("should collapse cart summary after view init", () => {
|
||||
const mockCartSummary = {
|
||||
isExpanded: signal(true),
|
||||
} as any;
|
||||
jest.spyOn(component, "cartSummaryComponent").mockReturnValue(mockCartSummary);
|
||||
|
||||
component.ngAfterViewInit();
|
||||
|
||||
expect(mockCartSummary.isExpanded()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Plan Price Calculation", () => {
|
||||
it("should calculate price for personal plan with annualPrice", () => {
|
||||
const price = component["getPlanPrice"](mockFamiliesPlan);
|
||||
expect(price).toBe(40);
|
||||
});
|
||||
|
||||
it("should calculate price for business plan with annualPricePerUser", () => {
|
||||
const price = component["getPlanPrice"](mockTeamsPlan);
|
||||
expect(price).toBe(48);
|
||||
});
|
||||
|
||||
it("should return 0 when passwordManager is missing", () => {
|
||||
const invalidPlan = { ...mockTeamsPlan, passwordManager: undefined } as any;
|
||||
const price = component["getPlanPrice"](invalidPlan);
|
||||
expect(price).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("processUpgrade", () => {
|
||||
beforeEach(() => {
|
||||
// Set paymentMethod signal for these tests
|
||||
component["paymentMethod"].set({
|
||||
type: "card",
|
||||
brand: "visa",
|
||||
last4: "4242",
|
||||
expiration: "12/2025",
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw error when billing address is incomplete", async () => {
|
||||
component["formGroup"].patchValue({
|
||||
organizationName: "Test Org",
|
||||
billingAddress: {
|
||||
country: "",
|
||||
postalCode: "",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(component["processUpgrade"]()).rejects.toThrow("Billing address is incomplete");
|
||||
});
|
||||
|
||||
it("should throw error when organization name is missing", async () => {
|
||||
component["formGroup"].patchValue({
|
||||
organizationName: "",
|
||||
billingAddress: {
|
||||
country: "US",
|
||||
postalCode: "12345",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(component["processUpgrade"]()).rejects.toThrow("Organization name is required");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Plan Membership Messages", () => {
|
||||
it("should return correct membership message for families plan", async () => {
|
||||
const newFixture = TestBed.createComponent(PremiumOrgUpgradePaymentComponent);
|
||||
const newComponent = newFixture.componentInstance;
|
||||
|
||||
newFixture.componentRef.setInput(
|
||||
"selectedPlanId",
|
||||
"families" as PersonalSubscriptionPricingTierId,
|
||||
);
|
||||
newFixture.componentRef.setInput("account", mockAccount);
|
||||
newFixture.detectChanges();
|
||||
await newFixture.whenStable();
|
||||
|
||||
expect(newComponent["planMembershipMessage"]()).toBe("familiesMembership");
|
||||
});
|
||||
|
||||
it("should return correct membership message for teams plan", () => {
|
||||
expect(component["planMembershipMessage"]()).toBe("teamsMembership");
|
||||
});
|
||||
|
||||
it("should return correct membership message for enterprise plan", async () => {
|
||||
const newFixture = TestBed.createComponent(PremiumOrgUpgradePaymentComponent);
|
||||
const newComponent = newFixture.componentInstance;
|
||||
|
||||
newFixture.componentRef.setInput(
|
||||
"selectedPlanId",
|
||||
"enterprise" as BusinessSubscriptionPricingTierId,
|
||||
);
|
||||
newFixture.componentRef.setInput("account", mockAccount);
|
||||
newFixture.detectChanges();
|
||||
await newFixture.whenStable();
|
||||
|
||||
expect(newComponent["planMembershipMessage"]()).toBe("enterpriseMembership");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error Handling", () => {
|
||||
it("should log error and continue when submit fails", async () => {
|
||||
jest.spyOn(component as any, "processUpgrade").mockRejectedValue(new Error("Network error"));
|
||||
|
||||
component["formGroup"].setValue({
|
||||
organizationName: "My New Org",
|
||||
billingAddress: {
|
||||
country: "US",
|
||||
postalCode: "90210",
|
||||
line1: "123 Main St",
|
||||
line2: "",
|
||||
city: "Beverly Hills",
|
||||
state: "CA",
|
||||
taxId: "",
|
||||
},
|
||||
});
|
||||
|
||||
await component["submit"]();
|
||||
|
||||
expect(mockLogService.error).toHaveBeenCalledWith("Upgrade failed:", expect.any(Error));
|
||||
expect(mockToastService.showToast).toHaveBeenCalledWith({
|
||||
variant: "error",
|
||||
message: "upgradeErrorMessage",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("goBack Output", () => {
|
||||
it("should emit goBack event when back action is triggered", () => {
|
||||
const goBackSpy = jest.spyOn(component["goBack"], "emit");
|
||||
component["goBack"].emit();
|
||||
expect(goBackSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,469 @@
|
||||
import {
|
||||
AfterViewInit,
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
DestroyRef,
|
||||
inject,
|
||||
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,
|
||||
map,
|
||||
tap,
|
||||
} from "rxjs";
|
||||
|
||||
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction";
|
||||
import {
|
||||
BusinessSubscriptionPricingTier,
|
||||
BusinessSubscriptionPricingTierId,
|
||||
PersonalSubscriptionPricingTier,
|
||||
PersonalSubscriptionPricingTierId,
|
||||
PersonalSubscriptionPricingTierIds,
|
||||
} 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 { SubscriberBillingClient } from "../../../clients/subscriber-billing.client";
|
||||
import {
|
||||
EnterBillingAddressComponent,
|
||||
getBillingAddressFromForm,
|
||||
DisplayPaymentMethodInlineComponent,
|
||||
} from "../../../payment/components";
|
||||
import { MaskedPaymentMethod } from "../../../payment/types";
|
||||
import { BitwardenSubscriber, mapAccountToSubscriber } from "../../../types";
|
||||
|
||||
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,
|
||||
EnterBillingAddressComponent,
|
||||
DisplayPaymentMethodInlineComponent,
|
||||
],
|
||||
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";
|
||||
private readonly PLAN_MEMBERSHIP_MESSAGES: Record<string, string> = {
|
||||
families: "familiesMembership",
|
||||
teams: "teamsMembership",
|
||||
enterprise: "enterpriseMembership",
|
||||
};
|
||||
private readonly UPGRADE_STATUS_MAP: Record<string, PremiumOrgUpgradePaymentStatus> = {
|
||||
families: PremiumOrgUpgradePaymentStatus.UpgradedToFamilies,
|
||||
teams: PremiumOrgUpgradePaymentStatus.UpgradedToTeams,
|
||||
enterprise: PremiumOrgUpgradePaymentStatus.UpgradedToEnterprise,
|
||||
};
|
||||
private readonly UPGRADE_MESSAGE_KEYS: Record<string, string> = {
|
||||
families: "upgradeToFamilies",
|
||||
teams: "upgradeToTeams",
|
||||
enterprise: "upgradeToEnterprise",
|
||||
};
|
||||
|
||||
protected readonly selectedPlanId = input.required<
|
||||
PersonalSubscriptionPricingTierId | BusinessSubscriptionPricingTierId
|
||||
>();
|
||||
protected readonly account = input.required<Account>();
|
||||
protected goBack = output<void>();
|
||||
protected complete = output<PremiumOrgUpgradePaymentResult>();
|
||||
|
||||
readonly cartSummaryComponent = viewChild.required(CartSummaryComponent);
|
||||
|
||||
protected formGroup = new FormGroup({
|
||||
organizationName: new FormControl<string>("", [Validators.required]),
|
||||
billingAddress: EnterBillingAddressComponent.getFormGroup(),
|
||||
});
|
||||
|
||||
protected readonly selectedPlan = signal<PremiumOrgUpgradePlanDetails | null>(null);
|
||||
protected readonly loading = signal(true);
|
||||
protected readonly upgradeToMessage = signal("");
|
||||
|
||||
// Signals for payment method
|
||||
protected readonly paymentMethod = signal<MaskedPaymentMethod | null>(null);
|
||||
protected readonly subscriber = signal<BitwardenSubscriber | null>(null);
|
||||
/**
|
||||
* Indicates whether the payment method is currently being changed.
|
||||
* This is used to disable the submit button while a payment method change is in progress.
|
||||
* or to hide other UI elements as needed.
|
||||
*/
|
||||
protected readonly isChangingPaymentMethod = signal(false);
|
||||
|
||||
protected readonly planMembershipMessage = computed<string>(
|
||||
() => this.PLAN_MEMBERSHIP_MESSAGES[this.selectedPlanId()] ?? "",
|
||||
);
|
||||
|
||||
// Use defer to lazily create the observable when subscribed to
|
||||
protected estimatedInvoice$ = defer(() =>
|
||||
combineLatest([this.formGroup.controls.billingAddress.valueChanges]).pipe(
|
||||
startWith(this.formGroup.controls.billingAddress.value),
|
||||
debounceTime(1000),
|
||||
switchMap(() => this.refreshInvoicePreview$()),
|
||||
),
|
||||
);
|
||||
|
||||
protected readonly estimatedInvoice = toSignal(this.estimatedInvoice$, {
|
||||
initialValue: this.getEmptyInvoicePreview(),
|
||||
});
|
||||
|
||||
private readonly i18nService = inject(I18nService);
|
||||
private readonly subscriptionPricingService = inject(SubscriptionPricingServiceAbstraction);
|
||||
private readonly toastService = inject(ToastService);
|
||||
private readonly logService = inject(LogService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly premiumOrgUpgradeService = inject(PremiumOrgUpgradeService);
|
||||
private readonly subscriberBillingClient = inject(SubscriberBillingClient);
|
||||
private readonly accountService = inject(AccountService);
|
||||
|
||||
constructor() {}
|
||||
// Cart Summary data
|
||||
protected readonly cart = computed<Cart>(() => {
|
||||
if (!this.selectedPlan()) {
|
||||
return {
|
||||
hidePricingTerm: true,
|
||||
passwordManager: {
|
||||
seats: {
|
||||
translationKey: this.planMembershipMessage(),
|
||||
cost: 0,
|
||||
quantity: 0,
|
||||
hideBreakdown: true,
|
||||
},
|
||||
},
|
||||
cadence: this.DEFAULT_CADENCE,
|
||||
estimatedTax: this.INITIAL_TAX_VALUE,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
hidePricingTerm: true,
|
||||
passwordManager: {
|
||||
seats: {
|
||||
translationKey: this.getMembershipTranslationKey(),
|
||||
translationParams: this.getMembershipTranslationParams(),
|
||||
cost: this.getCartCost(),
|
||||
quantity: this.DEFAULT_SEAT_COUNT,
|
||||
hideBreakdown: true,
|
||||
},
|
||||
},
|
||||
cadence: this.DEFAULT_CADENCE,
|
||||
estimatedTax: this.estimatedInvoice().tax,
|
||||
credit: {
|
||||
value: this.estimatedInvoice().credit,
|
||||
translationKey: "premiumSubscriptionCredit",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
// If the selected plan is Personal Premium, no upgrade is needed
|
||||
if (this.selectedPlanId() == PersonalSubscriptionPricingTierIds.Premium) {
|
||||
this.complete.emit({
|
||||
status: PremiumOrgUpgradePaymentStatus.Closed,
|
||||
organizationId: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
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.setSelectedPlan(planDetails);
|
||||
this.setUpgradeMessage(planDetails);
|
||||
} else {
|
||||
this.complete.emit({
|
||||
status: PremiumOrgUpgradePaymentStatus.Closed,
|
||||
organizationId: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
this.accountService.activeAccount$
|
||||
.pipe(
|
||||
mapAccountToSubscriber,
|
||||
switchMap((subscriber) =>
|
||||
from(this.subscriberBillingClient.getPaymentMethod(subscriber)).pipe(
|
||||
map((paymentMethod) => ({ subscriber, paymentMethod })),
|
||||
),
|
||||
),
|
||||
tap(({ subscriber, paymentMethod }) => {
|
||||
this.subscriber.set(subscriber);
|
||||
this.paymentMethod.set(paymentMethod);
|
||||
this.loading.set(false);
|
||||
}),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
const cartSummaryComponent = this.cartSummaryComponent();
|
||||
cartSummaryComponent.isExpanded.set(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the payment method when changed through the DisplayPaymentMethodComponent.
|
||||
* @param newPaymentMethod The updated payment method details
|
||||
*/
|
||||
handlePaymentMethodUpdate(newPaymentMethod: MaskedPaymentMethod) {
|
||||
this.paymentMethod.set(newPaymentMethod);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles changes to the payment method changing state.
|
||||
* @param isChanging Whether the payment method is currently being changed
|
||||
*/
|
||||
handlePaymentMethodChangingStateChange(isChanging: boolean) {
|
||||
this.isChangingPaymentMethod.set(isChanging);
|
||||
}
|
||||
|
||||
protected submit = async (): Promise<void> => {
|
||||
if (!this.formGroup.valid) {
|
||||
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("plansUpdated", 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"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
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 organizationId = await this.premiumOrgUpgradeService.upgradeToOrganization(
|
||||
this.account(),
|
||||
organizationName,
|
||||
this.selectedPlan()!,
|
||||
billingAddress,
|
||||
);
|
||||
|
||||
return {
|
||||
status: this.getUpgradeStatus(this.selectedPlanId()),
|
||||
organizationId,
|
||||
};
|
||||
}
|
||||
|
||||
private getUpgradeStatus(planId: string): PremiumOrgUpgradePaymentStatus {
|
||||
return this.UPGRADE_STATUS_MAP[planId] ?? PremiumOrgUpgradePaymentStatus.Closed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the appropriate translation key for the membership display.
|
||||
* Returns a prorated message if the plan has prorated months, otherwise returns the standard plan message.
|
||||
*/
|
||||
private getMembershipTranslationKey(): string {
|
||||
return this.estimatedInvoice()?.newPlanProratedMonths > 0
|
||||
? "planProratedMembershipInMonths"
|
||||
: this.planMembershipMessage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the translation parameters for the membership display.
|
||||
* For prorated plans, returns an array with the plan name and formatted month duration.
|
||||
* For non-prorated plans, returns an empty array.
|
||||
*/
|
||||
private getMembershipTranslationParams(): string[] {
|
||||
if (this.estimatedInvoice()?.newPlanProratedMonths > 0) {
|
||||
const months = this.estimatedInvoice()!.newPlanProratedMonths;
|
||||
const monthLabel = this.formatMonthLabel(months);
|
||||
return [this.selectedPlan()!.details.name, monthLabel];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats month count into a readable string (e.g., "1 month", "3 months").
|
||||
*/
|
||||
private formatMonthLabel(months: number): string {
|
||||
return `${months} month${months > 1 ? "s" : ""}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the cart cost, using prorated amount if available, otherwise the plan cost.
|
||||
*/
|
||||
private getCartCost(): number {
|
||||
const proratedAmount = this.estimatedInvoice().newPlanProratedAmount;
|
||||
return proratedAmount && proratedAmount > 0 ? proratedAmount : this.selectedPlan()!.cost;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the selected plan with tier, details, and cost.
|
||||
*/
|
||||
private setSelectedPlan(
|
||||
planDetails: PersonalSubscriptionPricingTier | BusinessSubscriptionPricingTier,
|
||||
): void {
|
||||
this.selectedPlan.set({
|
||||
tier: this.selectedPlanId(),
|
||||
details: planDetails,
|
||||
cost: this.getPlanPrice(planDetails),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the upgrade message based on the selected plan.
|
||||
*/
|
||||
private setUpgradeMessage(
|
||||
planDetails: PersonalSubscriptionPricingTier | BusinessSubscriptionPricingTier,
|
||||
): void {
|
||||
const messageKey = this.UPGRADE_MESSAGE_KEYS[this.selectedPlanId()];
|
||||
const message = messageKey ? this.i18nService.t(messageKey, planDetails.name) : "";
|
||||
this.upgradeToMessage.set(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an empty invoice preview with default values.
|
||||
*/
|
||||
private getEmptyInvoicePreview(): InvoicePreview {
|
||||
return {
|
||||
tax: this.INITIAL_TAX_VALUE,
|
||||
total: 0,
|
||||
credit: 0,
|
||||
newPlanProratedMonths: 0,
|
||||
newPlanProratedAmount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the invoice preview based on the current form state.
|
||||
*/
|
||||
private refreshInvoicePreview$(): Observable<InvoicePreview> {
|
||||
if (this.formGroup.invalid || !this.selectedPlan()) {
|
||||
return of(this.getEmptyInvoicePreview());
|
||||
}
|
||||
|
||||
const billingAddress = getBillingAddressFromForm(this.formGroup.controls.billingAddress);
|
||||
if (!billingAddress.country || !billingAddress.postalCode) {
|
||||
return of(this.getEmptyInvoicePreview());
|
||||
}
|
||||
|
||||
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(this.getEmptyInvoicePreview());
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
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>;
|
||||
let organizationService: jest.Mocked<OrganizationService>;
|
||||
|
||||
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([{ encryptedString: "encrypted-string" }, "decrypted-key"]),
|
||||
} as any;
|
||||
organizationService = {
|
||||
organizations$: jest.fn().mockReturnValue(
|
||||
of([
|
||||
{
|
||||
id: "new-org-id",
|
||||
name: "Test Organization",
|
||||
isOwner: true,
|
||||
} as Organization,
|
||||
]),
|
||||
),
|
||||
} 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 },
|
||||
{ provide: OrganizationService, useValue: organizationService },
|
||||
],
|
||||
});
|
||||
|
||||
service = TestBed.inject(PremiumOrgUpgradeService);
|
||||
});
|
||||
|
||||
describe("upgradeToOrganization", () => {
|
||||
it("should successfully upgrade premium account to organization and return organization ID", async () => {
|
||||
const result = await service.upgradeToOrganization(
|
||||
mockAccount,
|
||||
"Test Organization",
|
||||
mockPlanDetails,
|
||||
mockBillingAddress,
|
||||
);
|
||||
|
||||
expect(accountBillingClient.upgradePremiumToOrganization).toHaveBeenCalledWith(
|
||||
"Test Organization",
|
||||
"encrypted-string",
|
||||
2, // ProductTierType.Teams
|
||||
"annually",
|
||||
mockBillingAddress,
|
||||
);
|
||||
expect(keyService.makeOrgKey).toHaveBeenCalledWith("user-id");
|
||||
expect(syncService.fullSync).toHaveBeenCalledWith(true);
|
||||
expect(organizationService.organizations$).toHaveBeenCalledWith("user-id");
|
||||
expect(result).toBe("new-org-id");
|
||||
});
|
||||
|
||||
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 throw an error if encrypted string is undefined", async () => {
|
||||
keyService.makeOrgKey.mockResolvedValue([
|
||||
{ encryptedString: null } as any,
|
||||
"decrypted-key" as any,
|
||||
]);
|
||||
await expect(
|
||||
service.upgradeToOrganization(
|
||||
mockAccount,
|
||||
"Test Organization",
|
||||
mockPlanDetails,
|
||||
mockBillingAddress,
|
||||
),
|
||||
).rejects.toThrow("Failed to generate encrypted organization key");
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
it("should throw an error if organization is not found after sync", async () => {
|
||||
organizationService.organizations$.mockReturnValue(
|
||||
of([
|
||||
{
|
||||
id: "different-org-id",
|
||||
name: "Different Organization",
|
||||
isOwner: true,
|
||||
} as Organization,
|
||||
]),
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.upgradeToOrganization(
|
||||
mockAccount,
|
||||
"Test Organization",
|
||||
mockPlanDetails,
|
||||
mockBillingAddress,
|
||||
),
|
||||
).rejects.toThrow("Failed to find newly created organization");
|
||||
});
|
||||
|
||||
it("should throw an error if no organizations are returned", async () => {
|
||||
organizationService.organizations$.mockReturnValue(of([]));
|
||||
|
||||
await expect(
|
||||
service.upgradeToOrganization(
|
||||
mockAccount,
|
||||
"Test Organization",
|
||||
mockPlanDetails,
|
||||
mockBillingAddress,
|
||||
),
|
||||
).rejects.toThrow("Failed to find newly created organization");
|
||||
});
|
||||
});
|
||||
|
||||
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,129 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
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;
|
||||
proratedAmount?: number;
|
||||
};
|
||||
|
||||
export type PaymentFormValues = {
|
||||
organizationName?: string | null;
|
||||
billingAddress: {
|
||||
country: string;
|
||||
postalCode: string;
|
||||
};
|
||||
};
|
||||
|
||||
export interface InvoicePreview {
|
||||
tax: number;
|
||||
total: number;
|
||||
credit: number;
|
||||
newPlanProratedMonths: number;
|
||||
newPlanProratedAmount?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PremiumOrgUpgradeService {
|
||||
constructor(
|
||||
private accountBillingClient: AccountBillingClient,
|
||||
private previewInvoiceClient: PreviewInvoiceClient,
|
||||
private syncService: SyncService,
|
||||
private keyService: KeyService,
|
||||
private organizationService: OrganizationService,
|
||||
) {}
|
||||
|
||||
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,
|
||||
newPlanProratedMonths: invoicePreviewResponse.newPlanProratedMonths,
|
||||
newPlanProratedAmount: invoicePreviewResponse.newPlanProratedAmount,
|
||||
};
|
||||
}
|
||||
|
||||
async upgradeToOrganization(
|
||||
account: Account,
|
||||
organizationName: string,
|
||||
planDetails: PremiumOrgUpgradePlanDetails,
|
||||
billingAddress: BillingAddress,
|
||||
): Promise<string> {
|
||||
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);
|
||||
|
||||
if (!encryptedKey.encryptedString) {
|
||||
throw new Error("Failed to generate encrypted organization key");
|
||||
}
|
||||
|
||||
await this.accountBillingClient.upgradePremiumToOrganization(
|
||||
organizationName,
|
||||
encryptedKey.encryptedString,
|
||||
tier,
|
||||
SubscriptionCadenceIds.Annually,
|
||||
billingAddress,
|
||||
);
|
||||
|
||||
await this.syncService.fullSync(true);
|
||||
|
||||
// Get the newly created organization
|
||||
const organizations = await firstValueFrom(this.organizationService.organizations$(account.id));
|
||||
|
||||
const newOrg = organizations?.find((org) => org.name === organizationName && org.isOwner);
|
||||
|
||||
if (!newOrg) {
|
||||
throw new Error("Failed to find newly created organization");
|
||||
}
|
||||
|
||||
return newOrg.id;
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
@if (!loading()) {
|
||||
<section
|
||||
class="tw-w-screen tw-max-h-screen tw-min-w-[332px] md:tw-max-w-6xl tw-overflow-y-auto tw-self-center tw-bg-background tw-rounded-xl tw-shadow-lg tw-border-secondary-100 tw-border-solid tw-border"
|
||||
cdkTrapFocus
|
||||
cdkTrapFocusAutoCapture
|
||||
>
|
||||
<header class="tw-flex tw-items-center tw-justify-end tw-pl-6 tw-pt-3 tw-pr-2">
|
||||
<button
|
||||
cdkFocusInitial
|
||||
type="button"
|
||||
bitIconButton="bwi-close"
|
||||
buttonType="main"
|
||||
size="default"
|
||||
[label]="'close' | i18n"
|
||||
(click)="closeClicked.emit(closedStatus)"
|
||||
></button>
|
||||
</header>
|
||||
<div class="tw-px-14 tw-py-8">
|
||||
<div class="tw-flex tw-text-center tw-flex-col tw-pb-4">
|
||||
<h1 class="tw-font-medium tw-text-[32px]">
|
||||
{{ "upgradeYourPlan" | i18n }}
|
||||
</h1>
|
||||
<p bitTypography="body1" class="tw-text-muted">
|
||||
{{ "upgradeShareEvenMore" | i18n }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="tw-grid tw-grid-cols-1 md:tw-grid-cols-2 lg:tw-grid-cols-3 tw-gap-5 tw-mb-4">
|
||||
@if (familiesCardDetails) {
|
||||
<billing-pricing-card
|
||||
class="tw-w-full tw-min-w-[216px]"
|
||||
[tagline]="familiesCardDetails.tagline"
|
||||
[price]="familiesCardDetails.price"
|
||||
[button]="familiesCardDetails.button"
|
||||
[features]="familiesCardDetails.features"
|
||||
(buttonClick)="planSelected.emit(familiesPlanType)"
|
||||
>
|
||||
<h3 slot="title" class="tw-m-0" bitTypography="h3">
|
||||
{{ familiesCardDetails.title }}
|
||||
</h3>
|
||||
</billing-pricing-card>
|
||||
}
|
||||
|
||||
@if (teamsCardDetails) {
|
||||
<billing-pricing-card
|
||||
class="tw-w-full tw-min-w-[216px]"
|
||||
[tagline]="teamsCardDetails.tagline"
|
||||
[price]="teamsCardDetails.price"
|
||||
[button]="teamsCardDetails.button"
|
||||
[features]="teamsCardDetails.features"
|
||||
(buttonClick)="planSelected.emit(teamsPlanType)"
|
||||
>
|
||||
<h3 slot="title" class="tw-m-0" bitTypography="h3">
|
||||
{{ teamsCardDetails.title }}
|
||||
</h3>
|
||||
</billing-pricing-card>
|
||||
}
|
||||
|
||||
@if (enterpriseCardDetails) {
|
||||
<billing-pricing-card
|
||||
class="tw-w-full tw-min-w-[216px]"
|
||||
[tagline]="enterpriseCardDetails.tagline"
|
||||
[price]="enterpriseCardDetails.price"
|
||||
[button]="enterpriseCardDetails.button"
|
||||
[features]="enterpriseCardDetails.features"
|
||||
(buttonClick)="planSelected.emit(enterprisePlanType)"
|
||||
>
|
||||
<h3 slot="title" class="tw-m-0" bitTypography="h3">
|
||||
{{ enterpriseCardDetails.title }}
|
||||
</h3>
|
||||
</billing-pricing-card>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="tw-text-center tw-w-full">
|
||||
<p bitTypography="helper" class="tw-text-muted tw-italic">
|
||||
{{ "organizationUpgradeTaxInformationMessage" | i18n }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import { CdkTrapFocus } from "@angular/cdk/a11y";
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { By } from "@angular/platform-browser";
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { of, throwError } from "rxjs";
|
||||
|
||||
import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction";
|
||||
import {
|
||||
BusinessSubscriptionPricingTier,
|
||||
BusinessSubscriptionPricingTierIds,
|
||||
PersonalSubscriptionPricingTier,
|
||||
PersonalSubscriptionPricingTierIds,
|
||||
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { ToastService } from "@bitwarden/components";
|
||||
import { PricingCardComponent } from "@bitwarden/pricing";
|
||||
|
||||
import { BillingServicesModule } from "../../../services";
|
||||
|
||||
import { PremiumOrgUpgradePlanSelectionComponent } from "./premium-org-upgrade-plan-selection.component";
|
||||
|
||||
describe("PremiumOrgUpgradePlanSelectionComponent", () => {
|
||||
let sut: PremiumOrgUpgradePlanSelectionComponent;
|
||||
let fixture: ComponentFixture<PremiumOrgUpgradePlanSelectionComponent>;
|
||||
const mockI18nService = mock<I18nService>();
|
||||
const mockSubscriptionPricingService = mock<SubscriptionPricingServiceAbstraction>();
|
||||
const mockToastService = mock<ToastService>();
|
||||
|
||||
// Mock pricing tiers data
|
||||
const mockPersonalPricingTiers: PersonalSubscriptionPricingTier[] = [
|
||||
{
|
||||
id: PersonalSubscriptionPricingTierIds.Families,
|
||||
name: "planNameFamilies",
|
||||
description: "Family plan for up to 6 users",
|
||||
passwordManager: {
|
||||
type: "packaged",
|
||||
annualPrice: 40,
|
||||
features: [
|
||||
{ key: "feature1", value: "Feature A" },
|
||||
{ key: "feature2", value: "Feature B" },
|
||||
{ key: "feature3", value: "Feature C" },
|
||||
],
|
||||
users: 6,
|
||||
},
|
||||
} as PersonalSubscriptionPricingTier,
|
||||
];
|
||||
|
||||
const mockBusinessPricingTiers: BusinessSubscriptionPricingTier[] = [
|
||||
{
|
||||
id: BusinessSubscriptionPricingTierIds.Teams,
|
||||
name: "planNameTeams",
|
||||
description: "Teams plan for growing businesses",
|
||||
passwordManager: {
|
||||
type: "scalable",
|
||||
annualPricePerUser: 48,
|
||||
features: [
|
||||
{ key: "teamFeature1", value: "Teams Feature 1" },
|
||||
{ key: "teamFeature2", value: "Teams Feature 2" },
|
||||
{ key: "teamFeature3", value: "Teams Feature 3" },
|
||||
],
|
||||
},
|
||||
} as BusinessSubscriptionPricingTier,
|
||||
{
|
||||
id: BusinessSubscriptionPricingTierIds.Enterprise,
|
||||
name: "planNameEnterprise",
|
||||
description: "Enterprise plan for large organizations",
|
||||
passwordManager: {
|
||||
type: "scalable",
|
||||
annualPricePerUser: 72,
|
||||
features: [
|
||||
{ key: "entFeature1", value: "Enterprise Feature 1" },
|
||||
{ key: "entFeature2", value: "Enterprise Feature 2" },
|
||||
{ key: "entFeature3", value: "Enterprise Feature 3" },
|
||||
],
|
||||
},
|
||||
} as BusinessSubscriptionPricingTier,
|
||||
];
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.resetAllMocks();
|
||||
|
||||
mockI18nService.t.mockImplementation((key) => key);
|
||||
mockSubscriptionPricingService.getPersonalSubscriptionPricingTiers$.mockReturnValue(
|
||||
of(mockPersonalPricingTiers),
|
||||
);
|
||||
mockSubscriptionPricingService.getBusinessSubscriptionPricingTiers$.mockReturnValue(
|
||||
of(mockBusinessPricingTiers),
|
||||
);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [PremiumOrgUpgradePlanSelectionComponent, PricingCardComponent, CdkTrapFocus],
|
||||
providers: [
|
||||
{ provide: I18nService, useValue: mockI18nService },
|
||||
{
|
||||
provide: SubscriptionPricingServiceAbstraction,
|
||||
useValue: mockSubscriptionPricingService,
|
||||
},
|
||||
{ provide: ToastService, useValue: mockToastService },
|
||||
],
|
||||
})
|
||||
.overrideComponent(PremiumOrgUpgradePlanSelectionComponent, {
|
||||
remove: { imports: [BillingServicesModule] },
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(PremiumOrgUpgradePlanSelectionComponent);
|
||||
sut = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it("should create", () => {
|
||||
expect(sut).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should set loading to false after pricing tiers are loaded", () => {
|
||||
expect(sut["loading"]()).toBe(false);
|
||||
});
|
||||
|
||||
it("should set up pricing tier details for all three plans", () => {
|
||||
expect(sut["familiesCardDetails"]).toBeDefined();
|
||||
expect(sut["teamsCardDetails"]).toBeDefined();
|
||||
expect(sut["enterpriseCardDetails"]).toBeDefined();
|
||||
});
|
||||
|
||||
describe("card details creation", () => {
|
||||
it("should create families card details correctly", () => {
|
||||
expect(sut["familiesCardDetails"].title).toBe("planNameFamilies");
|
||||
expect(sut["familiesCardDetails"].tagline).toBe("Family plan for up to 6 users");
|
||||
expect(sut["familiesCardDetails"].price.amount).toBe(40 / 12);
|
||||
expect(sut["familiesCardDetails"].price.cadence).toBe("month");
|
||||
expect(sut["familiesCardDetails"].button.type).toBe("primary");
|
||||
expect(sut["familiesCardDetails"].button.text).toBe("upgradeToFamilies");
|
||||
expect(sut["familiesCardDetails"].features).toEqual(["Feature A", "Feature B", "Feature C"]);
|
||||
});
|
||||
|
||||
it("should create teams card details correctly", () => {
|
||||
expect(sut["teamsCardDetails"].title).toBe("planNameTeams");
|
||||
expect(sut["teamsCardDetails"].tagline).toBe("Teams plan for growing businesses");
|
||||
expect(sut["teamsCardDetails"].price.amount).toBe(48 / 12);
|
||||
expect(sut["teamsCardDetails"].price.cadence).toBe("month");
|
||||
expect(sut["teamsCardDetails"].button.type).toBe("secondary");
|
||||
expect(sut["teamsCardDetails"].button.text).toBe("upgradeToTeams");
|
||||
expect(sut["teamsCardDetails"].features).toEqual([
|
||||
"Teams Feature 1",
|
||||
"Teams Feature 2",
|
||||
"Teams Feature 3",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should create enterprise card details correctly", () => {
|
||||
expect(sut["enterpriseCardDetails"].title).toBe("planNameEnterprise");
|
||||
expect(sut["enterpriseCardDetails"].tagline).toBe("Enterprise plan for large organizations");
|
||||
expect(sut["enterpriseCardDetails"].price.amount).toBe(72 / 12);
|
||||
expect(sut["enterpriseCardDetails"].price.cadence).toBe("month");
|
||||
expect(sut["enterpriseCardDetails"].button.type).toBe("secondary");
|
||||
expect(sut["enterpriseCardDetails"].button.text).toBe("upgradeToEnterprise");
|
||||
expect(sut["enterpriseCardDetails"].features).toEqual([
|
||||
"Enterprise Feature 1",
|
||||
"Enterprise Feature 2",
|
||||
"Enterprise Feature 3",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("plan selection", () => {
|
||||
it("should emit planSelected with families pricing tier when families plan is selected", () => {
|
||||
const emitSpy = jest.spyOn(sut.planSelected, "emit");
|
||||
// The first PricingCardComponent corresponds to the families plan
|
||||
const familiesCard = fixture.debugElement.queryAll(By.directive(PricingCardComponent))[0];
|
||||
familiesCard.triggerEventHandler("buttonClick", {});
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(emitSpy).toHaveBeenCalledWith(PersonalSubscriptionPricingTierIds.Families);
|
||||
});
|
||||
|
||||
it("should emit planSelected with teams pricing tier when teams plan is selected", () => {
|
||||
const emitSpy = jest.spyOn(sut.planSelected, "emit");
|
||||
// The second PricingCardComponent corresponds to the teams plan
|
||||
const teamsCard = fixture.debugElement.queryAll(By.directive(PricingCardComponent))[1];
|
||||
teamsCard.triggerEventHandler("buttonClick", {});
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(emitSpy).toHaveBeenCalledWith(BusinessSubscriptionPricingTierIds.Teams);
|
||||
});
|
||||
|
||||
it("should emit planSelected with enterprise pricing tier when enterprise plan is selected", () => {
|
||||
const emitSpy = jest.spyOn(sut.planSelected, "emit");
|
||||
// The third PricingCardComponent corresponds to the enterprise plan
|
||||
const enterpriseCard = fixture.debugElement.queryAll(By.directive(PricingCardComponent))[2];
|
||||
enterpriseCard.triggerEventHandler("buttonClick", {});
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(emitSpy).toHaveBeenCalledWith(BusinessSubscriptionPricingTierIds.Enterprise);
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("should show toast and set loading to false on error", () => {
|
||||
mockSubscriptionPricingService.getPersonalSubscriptionPricingTiers$.mockReturnValue(
|
||||
throwError(() => new Error("API Error")),
|
||||
);
|
||||
mockSubscriptionPricingService.getBusinessSubscriptionPricingTiers$.mockReturnValue(
|
||||
of(mockBusinessPricingTiers),
|
||||
);
|
||||
|
||||
fixture = TestBed.createComponent(PremiumOrgUpgradePlanSelectionComponent);
|
||||
sut = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(mockToastService.showToast).toHaveBeenCalledWith({
|
||||
variant: "error",
|
||||
title: "",
|
||||
message: "unexpectedError",
|
||||
});
|
||||
expect(sut["loading"]()).toBe(false);
|
||||
expect(sut["familiesCardDetails"]).toBeUndefined();
|
||||
expect(sut["teamsCardDetails"]).toBeUndefined();
|
||||
expect(sut["enterpriseCardDetails"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import { CdkTrapFocus } from "@angular/cdk/a11y";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import {
|
||||
Component,
|
||||
DestroyRef,
|
||||
OnInit,
|
||||
output,
|
||||
signal,
|
||||
ChangeDetectionStrategy,
|
||||
} from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { combineLatest, catchError, of } from "rxjs";
|
||||
|
||||
import { SubscriptionPricingCardDetails } from "@bitwarden/angular/billing/types/subscription-pricing-card-details";
|
||||
import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction";
|
||||
import {
|
||||
BusinessSubscriptionPricingTier,
|
||||
BusinessSubscriptionPricingTierId,
|
||||
BusinessSubscriptionPricingTierIds,
|
||||
PersonalSubscriptionPricingTier,
|
||||
PersonalSubscriptionPricingTierId,
|
||||
PersonalSubscriptionPricingTierIds,
|
||||
} 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 { ButtonType, DialogModule, ToastService } from "@bitwarden/components";
|
||||
import { PricingCardComponent } from "@bitwarden/pricing";
|
||||
|
||||
import { SharedModule } from "../../../../shared";
|
||||
import { BillingServicesModule } from "../../../services";
|
||||
export const PremiumOrgUpgradeStatus = {
|
||||
Closed: "closed",
|
||||
ProceededToPayment: "proceeded-to-payment",
|
||||
} as const;
|
||||
|
||||
export type PremiumOrgUpgradeStatus = UnionOfValues<typeof PremiumOrgUpgradeStatus>;
|
||||
|
||||
@Component({
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
selector: "app-premium-org-upgrade-plan-selection",
|
||||
imports: [
|
||||
CommonModule,
|
||||
DialogModule,
|
||||
SharedModule,
|
||||
BillingServicesModule,
|
||||
PricingCardComponent,
|
||||
CdkTrapFocus,
|
||||
],
|
||||
templateUrl: "./premium-org-upgrade-plan-selection.component.html",
|
||||
})
|
||||
export class PremiumOrgUpgradePlanSelectionComponent implements OnInit {
|
||||
planSelected = output<PersonalSubscriptionPricingTierId | BusinessSubscriptionPricingTierId>();
|
||||
closeClicked = output<PremiumOrgUpgradeStatus>();
|
||||
protected closedStatus = PremiumOrgUpgradeStatus.Closed;
|
||||
|
||||
protected readonly loading = signal(true);
|
||||
protected familiesCardDetails!: SubscriptionPricingCardDetails;
|
||||
protected teamsCardDetails!: SubscriptionPricingCardDetails;
|
||||
protected enterpriseCardDetails!: SubscriptionPricingCardDetails;
|
||||
|
||||
protected familiesPlanType = PersonalSubscriptionPricingTierIds.Families;
|
||||
protected teamsPlanType = BusinessSubscriptionPricingTierIds.Teams;
|
||||
protected enterprisePlanType = BusinessSubscriptionPricingTierIds.Enterprise;
|
||||
|
||||
constructor(
|
||||
private i18nService: I18nService,
|
||||
private subscriptionPricingService: SubscriptionPricingServiceAbstraction,
|
||||
private toastService: ToastService,
|
||||
private destroyRef: DestroyRef,
|
||||
) {}
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
combineLatest([
|
||||
this.subscriptionPricingService.getPersonalSubscriptionPricingTiers$(),
|
||||
this.subscriptionPricingService.getBusinessSubscriptionPricingTiers$(),
|
||||
])
|
||||
.pipe(
|
||||
catchError((error: unknown) => {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: "",
|
||||
message: this.i18nService.t("unexpectedError"),
|
||||
});
|
||||
this.loading.set(false);
|
||||
return of([[], []]);
|
||||
}),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
)
|
||||
.subscribe(([personalPlans, businessPlans]) => {
|
||||
this.setupCardDetails(personalPlans, businessPlans);
|
||||
this.loading.set(false);
|
||||
});
|
||||
}
|
||||
|
||||
private setupCardDetails(
|
||||
personalPlans: PersonalSubscriptionPricingTier[],
|
||||
businessPlans: BusinessSubscriptionPricingTier[],
|
||||
): void {
|
||||
const familiesTier = personalPlans.find(
|
||||
(tier) => tier.id === PersonalSubscriptionPricingTierIds.Families,
|
||||
);
|
||||
const teamsTier = businessPlans.find(
|
||||
(tier) => tier.id === BusinessSubscriptionPricingTierIds.Teams,
|
||||
);
|
||||
const enterpriseTier = businessPlans.find(
|
||||
(tier) => tier.id === BusinessSubscriptionPricingTierIds.Enterprise,
|
||||
);
|
||||
|
||||
if (familiesTier) {
|
||||
this.familiesCardDetails = this.createCardDetails(familiesTier, "primary");
|
||||
}
|
||||
|
||||
if (teamsTier) {
|
||||
this.teamsCardDetails = this.createCardDetails(teamsTier, "secondary");
|
||||
}
|
||||
|
||||
if (enterpriseTier) {
|
||||
this.enterpriseCardDetails = this.createCardDetails(enterpriseTier, "secondary");
|
||||
}
|
||||
}
|
||||
|
||||
private createCardDetails(
|
||||
tier: PersonalSubscriptionPricingTier | BusinessSubscriptionPricingTier,
|
||||
buttonType: ButtonType,
|
||||
): SubscriptionPricingCardDetails {
|
||||
let buttonText: string;
|
||||
switch (tier.id) {
|
||||
case PersonalSubscriptionPricingTierIds.Families:
|
||||
buttonText = "upgradeToFamilies";
|
||||
break;
|
||||
case BusinessSubscriptionPricingTierIds.Teams:
|
||||
buttonText = "upgradeToTeams";
|
||||
break;
|
||||
case BusinessSubscriptionPricingTierIds.Enterprise:
|
||||
buttonText = "upgradeToEnterprise";
|
||||
break;
|
||||
default:
|
||||
buttonText = "";
|
||||
}
|
||||
|
||||
let priceAmount: number | undefined;
|
||||
let shouldShowPerUser = false;
|
||||
|
||||
if ("annualPrice" in tier.passwordManager) {
|
||||
priceAmount = tier.passwordManager.annualPrice;
|
||||
} else if ("annualPricePerUser" in tier.passwordManager) {
|
||||
priceAmount = tier.passwordManager.annualPricePerUser;
|
||||
shouldShowPerUser = true;
|
||||
}
|
||||
|
||||
return {
|
||||
title: tier.name,
|
||||
tagline: tier.description,
|
||||
price:
|
||||
priceAmount && priceAmount > 0
|
||||
? {
|
||||
amount: priceAmount / 12,
|
||||
cadence: "month",
|
||||
showPerUser: shouldShowPerUser,
|
||||
}
|
||||
: undefined,
|
||||
button: {
|
||||
text: this.i18nService.t(buttonText),
|
||||
type: buttonType,
|
||||
},
|
||||
features: tier.passwordManager.features.map((f: { key: string; value: string }) => f.value),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
selector: "app-upgrade-account",
|
||||
template: "",
|
||||
standalone: true,
|
||||
providers: [UpgradeAccountComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
class MockUpgradeAccountComponent {
|
||||
@@ -46,6 +47,7 @@ class MockUpgradeAccountComponent {
|
||||
selector: "app-upgrade-payment",
|
||||
template: "",
|
||||
standalone: true,
|
||||
providers: [UpgradePaymentComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
class MockUpgradePaymentComponent {
|
||||
@@ -61,7 +63,6 @@ describe("UnifiedUpgradeDialogComponent", () => {
|
||||
const mockDialogRef = mock<DialogRef>();
|
||||
const mockRouter = mock<Router>();
|
||||
const mockPremiumInterestStateService = mock<PremiumInterestStateService>();
|
||||
|
||||
const mockAccount: Account = {
|
||||
id: "user-id" as UserId,
|
||||
...mockAccountInfoWith({
|
||||
@@ -126,9 +127,8 @@ describe("UnifiedUpgradeDialogComponent", () => {
|
||||
|
||||
// Default mock: no premium interest
|
||||
mockPremiumInterestStateService.getPremiumInterest.mockResolvedValue(false);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [NoopAnimationsModule, UnifiedUpgradeDialogComponent],
|
||||
imports: [UnifiedUpgradeDialogComponent],
|
||||
providers: [
|
||||
{ provide: DialogRef, useValue: mockDialogRef },
|
||||
{ provide: DIALOG_DATA, useValue: defaultDialogData },
|
||||
@@ -401,4 +401,54 @@ describe("UnifiedUpgradeDialogComponent", () => {
|
||||
expect(mockDialogRef.close).toHaveBeenCalledWith({ status: "closed" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Child Component Display Logic", () => {
|
||||
it("should display app-upgrade-account on plan selection step", async () => {
|
||||
const { fixture } = await createComponentWithDialogData(defaultDialogData);
|
||||
|
||||
const upgradeAccountElement = fixture.nativeElement.querySelector("app-upgrade-account");
|
||||
|
||||
expect(upgradeAccountElement).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should display app-upgrade-payment on payment step", async () => {
|
||||
const customDialogData: UnifiedUpgradeDialogParams = {
|
||||
account: mockAccount,
|
||||
initialStep: UnifiedUpgradeDialogStep.Payment,
|
||||
selectedPlan: PersonalSubscriptionPricingTierIds.Premium,
|
||||
};
|
||||
|
||||
const { fixture } = await createComponentWithDialogData(customDialogData);
|
||||
|
||||
const upgradePaymentElement = fixture.nativeElement.querySelector("app-upgrade-payment");
|
||||
|
||||
expect(upgradePaymentElement).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("redirectOnCompletion", () => {
|
||||
it("should handle redirectOnCompletion for families upgrade with organization", async () => {
|
||||
const customDialogData: UnifiedUpgradeDialogParams = {
|
||||
account: mockAccount,
|
||||
redirectOnCompletion: true,
|
||||
};
|
||||
|
||||
mockRouter.navigate.mockResolvedValue(true);
|
||||
|
||||
const { component: customComponent } = await createComponentWithDialogData(customDialogData);
|
||||
|
||||
const result = {
|
||||
status: "upgradedToFamilies" as const,
|
||||
organizationId: "org-789",
|
||||
};
|
||||
|
||||
await customComponent["onComplete"](result);
|
||||
|
||||
expect(mockRouter.navigate).toHaveBeenCalledWith(["/organizations/org-789/vault"]);
|
||||
expect(mockDialogRef.close).toHaveBeenCalledWith({
|
||||
status: "upgradedToFamilies",
|
||||
organizationId: "org-789",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
>
|
||||
<header class="tw-flex tw-items-center tw-justify-end tw-pl-6 tw-pt-3 tw-pr-2">
|
||||
<button
|
||||
cdkFocusInitial
|
||||
type="button"
|
||||
bitIconButton="bwi-close"
|
||||
buttonType="main"
|
||||
|
||||
@@ -24,10 +24,10 @@
|
||||
<bit-form-field class="!tw-mb-0">
|
||||
<bit-label>{{ "organizationName" | i18n }}</bit-label>
|
||||
<input bitInput type="text" formControlName="organizationName" required />
|
||||
<bit-hint bitTypography="helper" class="tw-text-muted">
|
||||
{{ "organizationNameDescription" | i18n }}
|
||||
</bit-hint>
|
||||
</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">
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
viewChild,
|
||||
} from "@angular/core";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { ToastService, IconComponent } from "@bitwarden/components";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
import { SubscriberBillingClient } from "@bitwarden/web-vault/app/billing/clients";
|
||||
|
||||
import { SharedModule } from "../../../shared";
|
||||
import { BitwardenSubscriber } from "../../types";
|
||||
import { getCardBrandIcon, MaskedPaymentMethod, TokenizablePaymentMethods } from "../types";
|
||||
|
||||
import { EnterPaymentMethodComponent } from "./enter-payment-method.component";
|
||||
|
||||
/**
|
||||
* Component for inline editing of payment methods.
|
||||
* Displays a form to update payment method details directly within the parent view.
|
||||
*/
|
||||
@Component({
|
||||
selector: "app-display-payment-method-inline",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<bit-section>
|
||||
@if (!isChangingPayment()) {
|
||||
<h5 bitTypography="h5">{{ "paymentMethod" | i18n }}</h5>
|
||||
<div class="tw-flex tw-items-center tw-gap-2">
|
||||
@if (paymentMethod(); as pm) {
|
||||
@switch (pm.type) {
|
||||
@case ("bankAccount") {
|
||||
@if (pm.hostedVerificationUrl) {
|
||||
<p>
|
||||
{{ "verifyBankAccountWithStripe" | i18n }}
|
||||
<a
|
||||
bitLink
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
[attr.href]="pm.hostedVerificationUrl"
|
||||
>{{ "verifyNow" | i18n }}</a
|
||||
>
|
||||
</p>
|
||||
}
|
||||
|
||||
<p>
|
||||
<bit-icon name="bwi-billing"></bit-icon>
|
||||
{{ pm.bankName }}, *{{ pm.last4 }}
|
||||
@if (pm.hostedVerificationUrl) {
|
||||
<span>- {{ "unverified" | i18n }}</span>
|
||||
}
|
||||
</p>
|
||||
}
|
||||
@case ("card") {
|
||||
<p class="tw-flex tw-gap-2">
|
||||
@if (cardBrandIcon(); as icon) {
|
||||
<i class="bwi bwi-fw credit-card-icon {{ icon }}"></i>
|
||||
} @else {
|
||||
<bit-icon name="bwi-credit-card"></bit-icon>
|
||||
}
|
||||
{{ pm.brand | titlecase }}, *{{ pm.last4 }},
|
||||
{{ pm.expiration }}
|
||||
</p>
|
||||
}
|
||||
@case ("payPal") {
|
||||
<p>
|
||||
<bit-icon name="bwi-paypal" class="tw-text-primary-600"></bit-icon>
|
||||
{{ pm.email }}
|
||||
</p>
|
||||
}
|
||||
}
|
||||
} @else {
|
||||
<p bitTypography="body1">{{ "noPaymentMethod" | i18n }}</p>
|
||||
}
|
||||
@let key = paymentMethod() ? "changePaymentMethod" : "addPaymentMethod";
|
||||
<a
|
||||
bitLink
|
||||
linkType="primary"
|
||||
class="tw-cursor-pointer tw-mb-4"
|
||||
(click)="changePaymentMethod()"
|
||||
>
|
||||
{{ key | i18n }}</a
|
||||
>
|
||||
</div>
|
||||
} @else {
|
||||
<app-enter-payment-method
|
||||
#enterPaymentMethodComponent
|
||||
[includeBillingAddress]="true"
|
||||
[group]="formGroup"
|
||||
[showBankAccount]="true"
|
||||
[showAccountCredit]="false"
|
||||
>
|
||||
</app-enter-payment-method>
|
||||
<div class="tw-mt-4 tw-flex tw-gap-2">
|
||||
<button
|
||||
bitLink
|
||||
linkType="default"
|
||||
type="button"
|
||||
(click)="submit()"
|
||||
[disabled]="formGroup.invalid"
|
||||
>
|
||||
{{ "save" | i18n }}
|
||||
</button>
|
||||
<button bitLink linkType="subtle" type="button" (click)="cancel()">
|
||||
{{ "cancel" | i18n }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</bit-section>
|
||||
`,
|
||||
standalone: true,
|
||||
imports: [SharedModule, EnterPaymentMethodComponent, IconComponent],
|
||||
providers: [SubscriberBillingClient],
|
||||
})
|
||||
export class DisplayPaymentMethodInlineComponent {
|
||||
readonly subscriber = input.required<BitwardenSubscriber>();
|
||||
readonly paymentMethod = input.required<MaskedPaymentMethod | null>();
|
||||
readonly updated = output<MaskedPaymentMethod>();
|
||||
readonly changingStateChanged = output<boolean>();
|
||||
|
||||
protected formGroup = EnterPaymentMethodComponent.getFormGroup();
|
||||
|
||||
private readonly enterPaymentMethodComponent = viewChild<EnterPaymentMethodComponent>(
|
||||
EnterPaymentMethodComponent,
|
||||
);
|
||||
|
||||
protected readonly isChangingPayment = signal(false);
|
||||
protected readonly cardBrandIcon = computed(() => getCardBrandIcon(this.paymentMethod()));
|
||||
|
||||
private readonly billingClient = inject(SubscriberBillingClient);
|
||||
private readonly i18nService = inject(I18nService);
|
||||
private readonly toastService = inject(ToastService);
|
||||
private readonly logService = inject(LogService);
|
||||
|
||||
/**
|
||||
* Initiates the payment method change process by displaying the inline form.
|
||||
*/
|
||||
protected changePaymentMethod = async (): Promise<void> => {
|
||||
this.isChangingPayment.set(true);
|
||||
this.changingStateChanged.emit(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* Submits the payment method update form.
|
||||
* Validates the form, tokenizes the payment method, and sends the update request.
|
||||
*/
|
||||
protected submit = async (): Promise<void> => {
|
||||
try {
|
||||
if (!this.formGroup.valid) {
|
||||
this.formGroup.markAllAsTouched();
|
||||
throw new Error("Form is invalid");
|
||||
}
|
||||
|
||||
const component = this.enterPaymentMethodComponent();
|
||||
if (!component) {
|
||||
throw new Error("Payment method component not found");
|
||||
}
|
||||
|
||||
const paymentMethod = await component.tokenize();
|
||||
if (!paymentMethod) {
|
||||
throw new Error("Failed to tokenize payment method");
|
||||
}
|
||||
|
||||
const billingAddress =
|
||||
this.formGroup.value.type !== TokenizablePaymentMethods.payPal
|
||||
? this.formGroup.controls.billingAddress.getRawValue()
|
||||
: null;
|
||||
|
||||
await this.handlePaymentMethodUpdate(paymentMethod, billingAddress);
|
||||
} catch (error) {
|
||||
this.logService.error("Error submitting payment method update:", error);
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: "",
|
||||
message: this.i18nService.t("paymentMethodUpdateError"),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles the payment method update API call and result processing.
|
||||
*/
|
||||
private async handlePaymentMethodUpdate(paymentMethod: any, billingAddress: any): Promise<void> {
|
||||
const result = await this.billingClient.updatePaymentMethod(
|
||||
this.subscriber(),
|
||||
paymentMethod,
|
||||
billingAddress,
|
||||
);
|
||||
|
||||
switch (result.type) {
|
||||
case "success": {
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: "",
|
||||
message: this.i18nService.t("paymentMethodUpdated"),
|
||||
});
|
||||
this.updated.emit(result.value);
|
||||
this.isChangingPayment.set(false);
|
||||
this.changingStateChanged.emit(false);
|
||||
this.formGroup.reset();
|
||||
break;
|
||||
}
|
||||
case "error": {
|
||||
this.logService.error("Error submitting payment method update:", result);
|
||||
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: "",
|
||||
message: this.i18nService.t("paymentMethodUpdateError"),
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels the inline editing and resets the form.
|
||||
*/
|
||||
protected cancel = (): void => {
|
||||
this.formGroup.reset();
|
||||
this.changingStateChanged.emit(false);
|
||||
this.isChangingPayment.set(false);
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, EventEmitter, Input, Output } from "@angular/core";
|
||||
import { Component, EventEmitter, input, Input, Output } from "@angular/core";
|
||||
import { lastValueFrom } from "rxjs";
|
||||
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
@@ -15,7 +15,9 @@ import { ChangePaymentMethodDialogComponent } from "./change-payment-method-dial
|
||||
selector: "app-display-payment-method",
|
||||
template: `
|
||||
<bit-section>
|
||||
<h2 bitTypography="h2">{{ "paymentMethod" | i18n }}</h2>
|
||||
@if (!hideHeader()) {
|
||||
<h2 bitTypography="h2">{{ "paymentMethod" | i18n }}</h2>
|
||||
}
|
||||
@if (paymentMethod) {
|
||||
@switch (paymentMethod.type) {
|
||||
@case ("bankAccount") {
|
||||
@@ -81,6 +83,7 @@ export class DisplayPaymentMethodComponent {
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
|
||||
@Output() updated = new EventEmitter<MaskedPaymentMethod>();
|
||||
protected readonly hideHeader = input<boolean>(false);
|
||||
|
||||
constructor(private dialogService: DialogService) {}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ export * from "./add-account-credit-dialog.component";
|
||||
export * from "./change-payment-method-dialog.component";
|
||||
export * from "./display-account-credit.component";
|
||||
export * from "./display-billing-address.component";
|
||||
export * from "./display-payment-method-inline.component";
|
||||
export * from "./display-payment-method.component";
|
||||
export * from "./edit-billing-address-dialog.component";
|
||||
export * from "./enter-billing-address.component";
|
||||
|
||||
@@ -12794,5 +12794,54 @@
|
||||
},
|
||||
"perUser": {
|
||||
"message": "per user"
|
||||
},
|
||||
"upgradeToTeams": {
|
||||
"message": "Upgrade to Teams"
|
||||
},
|
||||
"upgradeToEnterprise": {
|
||||
"message": "Upgrade to Enterprise"
|
||||
},
|
||||
"upgradeShareEvenMore": {
|
||||
"message": "Share even more with Families, or get powerful, trusted password security with Teams or Enterprise"
|
||||
},
|
||||
"organizationUpgradeTaxInformationMessage": {
|
||||
"message": "Prices exclude tax and are billed annually."
|
||||
},
|
||||
"invoicePreviewErrorMessage": {
|
||||
"message": "Encountered an error while generating the invoice preview."
|
||||
},
|
||||
"planProratedMembershipInMonths": {
|
||||
"message": "Prorated $PLAN$ membership ($NUMOFMONTHS$)",
|
||||
"placeholders": {
|
||||
"plan": {
|
||||
"content": "$1",
|
||||
"example": "Families"
|
||||
},
|
||||
"numofmonths": {
|
||||
"content": "$2",
|
||||
"example": "6 Months"
|
||||
}
|
||||
}
|
||||
},
|
||||
"premiumSubscriptionCredit": {
|
||||
"message": "Premium subscription credit"
|
||||
},
|
||||
"enterpriseMembership": {
|
||||
"message": "Enterprise membership"
|
||||
},
|
||||
"teamsMembership": {
|
||||
"message": "Teams membership"
|
||||
},
|
||||
"plansUpdated": {
|
||||
"message": "You've upgraded to $PLAN$!",
|
||||
"placeholders": {
|
||||
"plan": {
|
||||
"content": "$1",
|
||||
"example": "Families"
|
||||
}
|
||||
}
|
||||
},
|
||||
"paymentMethodUpdateError": {
|
||||
"message": "There was an error updating your payment method."
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user